diff --git a/CHANGELOG.md b/CHANGELOG.md index 9673610..8f4e7ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,12 +12,89 @@ change is recorded here. speed's steps exactly on a worksheet, cites the model it follows, evaluates the speed's cube roots in `double`, and reports a `DomainError` on a steep descent, where the formula has no real answer. +- **`checked_symbol()`** builds a unit `Symbol` from run-time text -- a catalogue row, a configuration file -- byte + for byte, and returns `std::expected`: `SymbolError::TooLong` for text that does not fit, + `SymbolError::EmbeddedNull` for text holding a NUL byte. It never truncates and never aborts. `describe()` spells a + `SymbolError` for an error message, and `std::format` writes it in the same words. +- **A stable ASCII key per unit.** `Unit` gains `asciiText`, a key for serialising the unit -- a JSON annotation, a + database column, a client's choice -- that stays the same when its display symbol is restyled. + `view_ascii(unit)` returns it, or the symbol when no key is declared; `has_ascii_key(unit)` says whether that text + is printable ASCII; `checked_ascii_symbol()` builds a key from run-time text, refusing what `checked_symbol()` + refuses and any byte outside printable ASCII with `SymbolError::NotAscii`. The built-in units whose symbols are not + ASCII declare keys: `PerMille` `permille`, `Micrometre` `um`, `Celsius` `degC`, `Fahrenheit` `degF`. A trace's + derived quotient unit carries a key when either of its units declares one (`um/s` for `µm/s`). The key is never + displayed: renderings, traces, `number_text` and `std::format` still write the symbol. +- **One-sided bounds, and limits checked at run time.** `at_least(numerator, denominator)` declares a unit's minimum + only, and `at_most(numerator, denominator)` its maximum only, beside `bounds()`, which declares both. + `checked_within(value, lowEnd, highEnd)` checks a `Rational` or a `Measured` against limits held at run time -- a + specification row, a catalogue entry -- each a `std::optional` that may be absent, with no `Unit` built + to carry them. Both ends are inclusive; with no end it reports `NotChecked`, never `WithinBounds`; a lower end + above the upper one is refused as `DomainError`; an absent measurement is `NotMeasured`. `within` is the same + check spelled to throw. `checked_within_bounds` applies the same rule to the ends a unit declares. +- **A quantity's own decimal places, and `same_unit`.** `Quantity` takes an optional fifth parameter, a + `DecimalPlaces` that defaults to the places its unit declares: `Quantity` reads milliamperes to a tenth while the unit, and every other quantity in it, keeps its own + places. The quantity's `unit` carries those places: `checked_round_to_declared` rounds to them, and + `number_text`, `std::format` and traces pad to them and round an approximation to them, while an exact decimal is + still written in full. `same_unit(leftUnit, rightUnit)` says whether two units are the same unit -- the same + dimension, factor and offset as declared, symbol and key (`view_ascii`) -- whatever their declared decimals and + bounds, which `==`, comparing every member, does not answer. A factor of 2/2000 is not the same as 1/1000 there. +- `parse_decimal_text` reads decimal text that arrives at run time -- a CSV import, a form field, a configuration + value -- into a `ParsedDecimal`: its exact value and the places it was typed to, so `"2.400"` is 12/5 at 3 places + and `"2.4"` is 12/5 at 1. `Rational::from_decimal_text` gives the value alone. Text that is not a decimal + (whitespace, a decimal comma, separators, `inf`, `nan`) is `DomainError`. Text beyond the parser's range is + `Overflow`, for example digits above 2^127 - 1 in magnitude, or a scale outside 10^-38 to 10^38 once trailing zeros + fold. The same parser reads `_r` literals. +- `checked_transform` and `checked_combine` apply a callback that can fail to measured values: it returns + `std::expected`, as `checked_mul` does, and its error comes back unchanged. An absent + value stays absent without calling it. Both are `noexcept` when the callback is, so + `checked_transform(reading, [](Rational litres) noexcept { return checked_mul(litres, Rational { 10 }); })` can be + written under a no-throw rule. A callback that returns a bare `Rational` does not compile; it belongs to + `transform` or `combine`. Nor does one that cannot be called with a `Rational`, which is refused with + `formula: a checked_transform callback must be callable with a Rational`, or, for `checked_combine`, with + `formula: a checked_combine callback must be callable with two Rationals`. ### Changed +- **Breaking:** `symbol()` is `consteval`: its spelling must be a constant expression, so a symbol too long for + `SymbolCapacity` can no longer reach `std::abort()` at run time. Run-time text goes through `checked_symbol()`. +- **Breaking:** `SymbolCapacity` is 32 bytes including the terminator, up from 16: a unit symbol or a named base's + name holds 31 bytes, enough for compound laboratory units such as `µmol/(L·min·kg)` (18 bytes of UTF-8). `Symbol` + is 16 bytes larger, and `Dimension` and `Unit`, which hold symbols, are larger with it, as is a trace's `Step`, + which holds a dimension and two units, so a trace takes more memory; the longest text `number_text` spells grows + from 97 to 113 bytes, still within `NumberTextCapacity`. A trace shows an `opaque_output` in the quotient of its + inputs' units, `AcmeGrams/AcmeVials` for instance, when that quotient's symbol is 16 to 31 bytes long, where it + used to fall back to the coherent unit. +- **Breaking:** a unit whose symbol is not ASCII must declare an ASCII key, `.asciiText = formula::symbol("ug/L")` + for a symbol written `µg/L`, where the library takes it as a quantity's, constant's, rounding's, table's or other + formula node's unit. Without one that use no longer compiles, with + `formula: a unit whose symbol is not ASCII must declare an ASCII key`; a key that is itself not ASCII is refused + the same way. `Unit` is larger by the new `Symbol` member, which follows `symbolText`: a `Unit` built with + positional initialisers no longer compiles or puts its values in the wrong members, while designated initialisers + are unaffected. `Unit { dimension, 1, 1000, 0, 1, symbol("g"), 2 }` still compiles, but the 2 meant for `decimals` + becomes the key's first byte, 0x02, which is not printable, so every use that checks the key refuses the unit, and + `decimals` is back at its default of 3. Unit equality compares `asciiText` too, so two units that differ only in + their key are no longer equal: a micrometre built at run time without a key is not `unit::Micrometre`. To compare + what a unit is, ignoring its decimals and bounds, use `same_unit`, which compares keys through `view_ascii`: that + micrometre, keyed `µm`, is not the same unit as `unit::Micrometre`, keyed `um`, either. +- **Breaking:** `Bounds::present` is replaced by `lowPresent` and `highPresent`, one for each end, so a unit can + declare a minimum or a maximum alone. Each is a `BoundsEnd`, which reads as a `bool` but takes nothing else, so a + `Bounds` built with positional initialisers in the old order no longer compiles: in `{ true, 0, 1, 100, 1 }`, once + a range of 0 to 100, the 0 lands on `highPresent` and is refused, rather than every value moving one member later. + `{}` and `{ false }` still compile and declare no bounds. `{ true }` alone still compiles too, but where it once + declared a range of 0 to 0 it now declares a minimum of 0 and no maximum. Write `bounds()`, `at_least()`, + `at_most()` or designated initialisers instead, which are unaffected. Code that read `present` reads + `lowPresent || highPresent`, or each end on its own. A unit that declared bounds with `bounds()` gives the same + answers as before. - The README and the documentation home page now lead with the cyclist's speed from power. The guides and the other examples use a road gradient, `s = h / L`, wherever they need a simple exact division. +- `_r` literals take a 128-bit mantissa, so every integer up to 2^127 - 1 in magnitude can be written as one + (`12'345'678'901'234'567'890_r` compiles), and `Rational::from_decimal` and `_r` scale by powers of ten from + 10^-38 to 10^38 rather than stopping at 10^18. `from_decimal` folds a mantissa's trailing zeros into a negative + exponent first, so `from_decimal(10, -39)` is 1/10^38. A zero literal with an exponent beyond ±1000, such as + `0e1001_r`, now reads as 0 rather than being refused, as the same text does at run time. Only refusals turn into + answers: every value that answered before is unchanged. ## [0.4.0] - 2026-10-05 diff --git a/README.md b/README.md index cdcd990..02a6b0e 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,13 @@ the outcome or the error, and neither is read unchecked. The area is held exactly, with `formula::pi` an exact fraction close to pi, and has no short decimal, so it is printed rounded to six places and marked `≈`. +To serialise a unit — a JSON annotation, a database column — write its stable +ASCII key, `formula::view_ascii(someUnit)`, not its display symbol, which may +be restyled. A unit whose symbol is not ASCII declares the key as `.asciiText` +(`unit::Micrometre`, shown `µm`, is keyed `um`); one that does not is refused +where the library takes it as a quantity's, constant's, rounding's, table's or +other formula node's unit. + ### A measurement nobody took stays missing ```cpp diff --git a/docs/citations.md b/docs/citations.md index 044a6ce..22d45a5 100644 --- a/docs/citations.md +++ b/docs/citations.md @@ -150,22 +150,22 @@ which gives, verbatim but for the paths, shown relative to the repository, on MSVC's `cl.exe` (19.51, `cl-debug` preset): ``` -include\formula-cpp/expression.hpp(189): error C2338: static assertion failed: 'formula: the two sides of this addition or subtraction measure different dimensions; the offending operands appear in this diagnostic as the template arguments of RequireAddendsAgree' -include\formula-cpp/expression.hpp(189): note: the template instantiation context (the oldest one first) is +include\formula-cpp/expression.hpp(191): error C2338: static assertion failed: 'formula: the two sides of this addition or subtraction measure different dimensions; the offending operands appear in this diagnostic as the template arguments of RequireAddendsAgree' +include\formula-cpp/expression.hpp(191): note: the template instantiation context (the oldest one first) is test\negative\documented_dimension_mismatch.cpp(14): note: see reference to function template instantiation 'auto formula::operator +>,formula::VarNode>(Left,Right) noexcept' being compiled with [ Left=formula::DocumentedNode>, Right=formula::VarNode ] -include\formula-cpp/expression.hpp(274): note: see reference to class template instantiation 'formula::BinaryNode>,formula::VarNode>' being compiled -include\formula-cpp/expression.hpp(247): note: see reference to class template instantiation 'formula::detail::AdditiveDimensionsAgree' being compiled +include\formula-cpp/expression.hpp(276): note: see reference to class template instantiation 'formula::BinaryNode>,formula::VarNode>' being compiled +include\formula-cpp/expression.hpp(249): note: see reference to class template instantiation 'formula::detail::AdditiveDimensionsAgree' being compiled with [ Left=formula::DocumentedNode>, Right=formula::VarNode ] -include\formula-cpp/expression.hpp(206): note: see reference to class template instantiation 'formula::detail::RequireAddendsAgree' being compiled +include\formula-cpp/expression.hpp(208): note: see reference to class template instantiation 'formula::detail::RequireAddendsAgree' being compiled with [ Left=formula::DocumentedNode>, diff --git a/docs/dimensions.md b/docs/dimensions.md index b4ba041..96246e4 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -3,10 +3,10 @@ `formula-cpp` provides a compile-time dimension vector, `formula::Dimension`, and a unit descriptor built on top of it, `formula::Unit`. This page explains why a dimension is a type rather than a runtime tag, how to compose one, why -its exponents are rational rather than integer, what a `Unit` carries, how -conversion between units stays exact, where the declared-precision and -bounds machinery sits, and how an application declares a base dimension the -SI does not have, such as money. +its exponents are rational rather than integer, what a `Unit` carries, how a +unit is keyed for serialising and compared, how conversion between units stays +exact, where the declared-precision and bounds machinery sits, and how an +application declares a base dimension the SI does not have, such as money. The worked example is `examples/dimensions_and_units.cpp`. **Program output** on this page is copied verbatim from that program's output, and @@ -17,11 +17,11 @@ source, and `docs.dimensions-snippets` fails unless each code block appears there as a run of consecutive lines, compared without their indentation (`cmake/CheckGuideSnippets.cmake`). A code block deliberately not from the example carries a `` comment directly -above it; one on this page does, the block showing that -`formula::exponent(1, 0)` does not compile. A couple of numeric facts that -the example does not itself print are given as plain rationals instead, each -naming the `static_assert` in the test suite that pins it -- never formatted -as if a program had printed them. +above it; two on this page do, the block declaring a unit with an ASCII key +and the block showing that `formula::exponent(1, 0)` does not compile. A couple +of numeric facts that the example does not itself print are given as plain +rationals instead, each naming the `static_assert` in the test suite that pins +it -- never formatted as if a program had printed them. ## Why dimensions are types @@ -131,8 +131,9 @@ A `formula::Unit` is a small aggregate, and every field earns its place: | `magnitudeNumerator` / `magnitudeDenominator` | the exact multiplicative factor to the coherent unit -- the coherent SI unit, times one of each named base dimension -- as an integer ratio | | `offsetNumerator` / `offsetDenominator` | the exact additive offset, for an affine scale such as degrees Celsius or degrees Fahrenheit | | `symbolText` | a fixed-capacity display symbol (a `Symbol`, not a `std::string_view`); required for a dimensionless unit with a scale | +| `asciiText` | a stable ASCII key for serialising the unit, read with `view_ascii`; empty when the symbol is its own key, required when the symbol is not ASCII | | `decimals` | the declared display precision | -| `bounds` | an optional valid range, in the unit's own scale | +| `bounds` | an optional valid range -- a minimum, a maximum, or both -- in the unit's own scale | Like `Dimension`, `Unit` is structural on purpose: a quantity (see [Quantities and measurements](quantities.md)) names its unit as a template @@ -188,9 +189,74 @@ number, and every conversion here is by exact rational magnitude; a `Degree` would have to be either inexact or unconvertible, and neither is a choice to make silently. -Their `decimals` values are ordinary engineering -defaults, not a requirement taken from any standard -- a caller that needs a -different precision states it at the point of use. +Their `decimals` values are ordinary engineering defaults, not a requirement +taken from any standard -- a caller that needs a different precision states it +at the point of use, or declares it on the quantity (see +[Quantities and measurements](quantities.md#a-quantitys-own-decimal-places)). + +### A stable ASCII key + +A unit's symbol is for display, and may be restyled: `µ` or `u`, a middle dot, +a superscript. Code that serialises a unit -- a JSON annotation, a database +column naming a unit, a client's choice of unit -- needs a name that stays the +same, and reads it with `formula::view_ascii(unit)`: the unit's `asciiText` +when it declares one, otherwise its `symbolText`. ASCII here means printable +ASCII, the bytes 0x20 to 0x7E; the empty text counts. + +A unit whose symbol is not ASCII must declare its key: + + +```cpp +inline constexpr formula::Unit MicrogramPerLitre { .dimension = formula::dim::Mass / formula::dim::Volume, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1'000'000, + .symbolText = formula::symbol("µg/L"), + .asciiText = formula::symbol("ug/L") }; +``` + +Without the key, the unit is refused wherever it is written -- as a quantity's, +constant's, rounding's, table's or other formula node's unit, the same places +a dimensionless unit with a scale and no symbol is refused -- with +`formula: a unit whose symbol is not ASCII must declare an ASCII key`. A key +that is itself not ASCII is refused the same way: the key is what a serialiser +trusts. The four built-in units whose symbols are not ASCII declare theirs: + +| Unit | Symbol | `asciiText` | +|---|---|---| +| `PerMille` | `‰` | `permille` | +| `Micrometre` | `µm` | `um` | +| `Celsius` | `°C` | `degC` | +| `Fahrenheit` | `°F` | `degF` | + +A unit built at run time is not checked where it is written, so ask +`formula::has_ascii_key(unit)` before trusting `view_ascii`; build its key from +run-time text with `formula::checked_ascii_symbol()`, which refuses what +`checked_symbol()` refuses and returns `SymbolError::NotAscii` for any byte +outside printable ASCII. A trace's derived quotient unit carries a key when +either of its units declares one: micrometres per second are shown as `µm/s` +and keyed `um/s`. + +The key is never displayed: `render()`, traces, `number_text` and +`std::format` write `symbolText`. It can still decide which unit a trace shows: +when either unit declares a key, the quotient's key must fit a `Symbol` and +hold no `/`. When it does not, the trace borrows no quotient of those two units, +even where their symbols would fit, and falls back to the coherent unit. + +### Comparing units + +`==` compares every member of two units, `asciiText`, `decimals` and `bounds` +among them: a unit declared to one decimal place is not `==` to the same unit +declared to none. `formula::same_unit(leftUnit, rightUnit)` asks whether two +units are the same unit: the same dimension, the same factor and offset as +declared, the same `symbolText` and the same key (`view_ascii`), whatever their +decimals and bounds. The factor and offset are compared as the integer pairs +written, not reduced: a unit declared with a factor of 2/2000 is not the same +unit as one declared with 1/1000, though it converts identically. A key +declared equal to the symbol is the same key as none declared. +Use `same_unit`, or `view_ascii`, to key a table by unit, and to ask whether two +quantities that declare their own decimal places (see +[Quantities and measurements](quantities.md#a-quantitys-own-decimal-places)) +share a unit. ## Exact conversion @@ -340,6 +406,47 @@ holds nothing. A reading nobody took and a range nobody declared are different facts, for the same reason `NotChecked` is not `WithinBounds`. `formula::describe(BoundsCheck)` gives each outcome its own non-empty, mutually distinct wording, as shown above. +A unit declares each end of its range on its own: `Bounds` holds `lowPresent` +and `highPresent`, one for each end. `formula::bounds(lowNumerator, +lowDenominator, highNumerator, highDenominator)` declares both, +`formula::at_least(numerator, denominator)` a minimum only, and +`formula::at_most(numerator, denominator)` a maximum only. Both ends are +inclusive, and a unit that declares neither reports `NotChecked`. Each flag is +a `BoundsEnd`, which reads as a `bool` but only a `bool` sets, so a `Bounds` +written positionally before the two flags existed no longer compiles: in +`{ true, 0, 1, 100, 1 }`, once 0 to 100, the 0 lands on `highPresent`. Only +`{}` and `{ false }`, which declare no bounds, and `{ true }`, which once meant +0 to 0 and now declares a minimum of 0, still compile. Write `bounds()`, +`at_least()`, `at_most()` or designated initialisers. + +Limits known only at run time -- a specification row, a catalogue entry -- need +no unit to carry them. `formula::checked_within(value, lowEnd, highEnd)` takes +each end as a `std::optional`, and either may be absent. Here a +catalogue row gives the gauge a minimum and no maximum: + +```cpp +std::optional const catalogueMinimum = Rational { 25 }; +Rational const gaugeReading = 42; +auto const catalogueVerdict = formula::checked_within(gaugeReading, catalogueMinimum, std::nullopt); +if (!catalogueVerdict) +{ + std::println("checking the gauge against the catalogue: {}", catalogueVerdict.error()); + return 1; +} +std::println("gauge at 42% against a catalogue minimum of 25%: {}", *catalogueVerdict); +``` + +```text +gauge at 42% against a catalogue minimum of 25%: within the declared bounds +``` + +It answers by the rule `checked_within_bounds` applies to a unit's declared +ends: `NotChecked` when neither end is given, never `WithinBounds`, and +`DomainError` when the lower end is above the upper one, a malformed pair of +limits rather than a value to judge. Given a `Measured`, it reports +`NotMeasured` for a value nobody took. `formula::within` is the same check +spelled to throw. + ## Base dimensions the SI does not have The seven SI base quantities describe physics, and formulas are often about @@ -425,7 +532,7 @@ shows the two additions it refuses. The library itself declares no currency: the application's. **Names.** A base's name must be an ASCII letter followed by ASCII letters or -digits, at most 15 bytes long -- it is a `Symbol`, as a unit's symbol is -- +digits, at most 31 bytes long -- it is a `Symbol`, as a unit's symbol is -- and not the symbol of an SI base unit: `m`, `kg`, `s`, `A`, `K`, `mol` or `cd`, since a base named `m` would read as metres wherever it is printed. And it is printed: it is the symbol of the base's coherent unit, written into a trace @@ -497,10 +604,19 @@ the same kind, `formula_dimension_has_too_many_named_bases` -- a compile error in a constant expression, an abort at run time -- never by dropping a base. A named base's exponent is an `Exponent`, with the limits above. -`SymbolCapacity` is 16 bytes **including the terminator** -- 15 usable -characters, not 16 -- and a base's name is a `Symbol` too, so it is at most 15 -bytes long. The name is checked only where it is made, and `base_dimension` is -`consteval`, so its four sentinels are always compile errors, never aborts: +`SymbolCapacity` is 32 bytes **including the terminator** -- 31 usable +bytes, not 32 -- which holds compound laboratory units such as +`µmol/(L·min·kg)` (18 bytes of UTF-8). `symbol()` is `consteval`: it accepts +only a constant expression, so a literal that does not fit is a compile error +naming `formula_unit_symbol_too_long`, and no run-time text can reach it. A +symbol from run-time text -- a catalogue row, a configuration file -- is built +with `checked_symbol()`, which returns the `Symbol` byte for byte, or +`SymbolError::TooLong` or `SymbolError::EmbeddedNull` instead of aborting or +truncating; `describe()` spells either for an error message. + +A base's name is a `Symbol` too, so it is at most 31 bytes long. The name is +checked only where it is made, and `base_dimension` is `consteval`, so its four +sentinels are always compile errors, never aborts: `formula_base_dimension_name_must_not_be_empty`, `formula_base_dimension_name_too_long`, `formula_base_dimension_name_must_be_a_letter_then_letters_or_digits` and @@ -508,7 +624,7 @@ bytes long. The name is checked only where it is made, and `base_dimension` is filled by hand is checked by none of them. `Unit`'s `magnitudeNumerator`, `magnitudeDenominator`, -`offsetNumerator`, `offsetDenominator` and the four fields of `Bounds` are all +`offsetNumerator`, `offsetDenominator` and the four integer fields of `Bounds` are all `std::int64_t`. `Rational`'s own numerator and denominator are 128-bit, so every value these fields state converts to one exactly. diff --git a/docs/expressions.md b/docs/expressions.md index 8dd4b23..7ead701 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -67,22 +67,22 @@ the repository, on MSVC's `cl.exe` (19.51, from Visual Studio's `cl-debug` preset): ``` -include\formula-cpp/expression.hpp(189): error C2338: static assertion failed: 'formula: the two sides of this addition or subtraction measure different dimensions; the offending operands appear in this diagnostic as the template arguments of RequireAddendsAgree' -include\formula-cpp/expression.hpp(189): note: the template instantiation context (the oldest one first) is +include\formula-cpp/expression.hpp(191): error C2338: static assertion failed: 'formula: the two sides of this addition or subtraction measure different dimensions; the offending operands appear in this diagnostic as the template arguments of RequireAddendsAgree' +include\formula-cpp/expression.hpp(191): note: the template instantiation context (the oldest one first) is test\negative\quantity_alias_add_dimension_mismatch.cpp(12): note: see reference to function template instantiation 'auto formula::operator +,formula::VarNode>(Left,Right) noexcept' being compiled with [ Left=formula::VarNode, Right=formula::VarNode ] -include\formula-cpp/expression.hpp(274): note: see reference to class template instantiation 'formula::BinaryNode,formula::VarNode>' being compiled -include\formula-cpp/expression.hpp(247): note: see reference to class template instantiation 'formula::detail::AdditiveDimensionsAgree' being compiled +include\formula-cpp/expression.hpp(276): note: see reference to class template instantiation 'formula::BinaryNode,formula::VarNode>' being compiled +include\formula-cpp/expression.hpp(249): note: see reference to class template instantiation 'formula::detail::AdditiveDimensionsAgree' being compiled with [ Left=formula::VarNode, Right=formula::VarNode ] -include\formula-cpp/expression.hpp(206): note: see reference to class template instantiation 'formula::detail::RequireAddendsAgree' being compiled +include\formula-cpp/expression.hpp(208): note: see reference to class template instantiation 'formula::detail::RequireAddendsAgree' being compiled with [ Left=formula::VarNode, diff --git a/docs/numbers.md b/docs/numbers.md index 7d7e9b6..9f7578f 100644 --- a/docs/numbers.md +++ b/docs/numbers.md @@ -53,6 +53,7 @@ is the only place precision is deliberately given up. | a fraction | `Rational { 3, 4 }` | 3/4 | | an exact decimal | `0.45_r` | 9/20 | | an exact decimal from digits known only at run time | `Rational::from_decimal(45, -2)` | 9/20 | +| an exact decimal from text read at run time | `Rational::from_decimal_text("0.45")` | 9/20 | | a whole number of tens, the same way | `Rational::from_decimal(45, 1)` | 450/1 | | the exact value of a `double` | `Rational::from_double_exact(0.45)` | a power-of-two denominator | | a measured `double` on a known scale | `rational_from_double(0.45, DecimalPlaces { 2 }, mode)` | 9/20 | @@ -75,12 +76,15 @@ Rational const g = `0.45_r` is read from its spelling at compile time ([Writing an exact decimal](#writing-an-exact-decimal)). `from_decimal` writes the same number from a mantissa and a power of ten that may be known -only at run time. - -`from_decimal`, `from_double_exact` and `rational_from_double` all return -`std::expected` because the conversion can fail -- -`from_decimal`'s scale factor can overflow, and `from_double_exact` can be -asked for a NaN or an infinity. `*` unwraps a value known to be present; use +only at run time; its scale reaches from 10^-38 to 10^38. `from_decimal_text` +reads it from text that arrives at run time +([Decimal text at run time](#decimal-text-at-run-time)). + +`from_decimal`, `from_decimal_text`, `from_double_exact` and +`rational_from_double` all return `std::expected` +because the conversion can fail -- `from_decimal`'s scale factor can +overflow, `from_decimal_text` can be given text that is not a decimal, and +`from_double_exact` can be asked for a NaN or an infinity. `*` unwraps a value known to be present; use the `checked_` layer described below when the input is not already known-good. `Rational r = 0.45;` does **not** compile. `Rational` has a constructor @@ -116,8 +120,9 @@ compile. The diagnostic names the function that was reached: | Spelling | Why it is refused | Named in the diagnostic | |---|---|---| -| `9'223'372'036'854'775'808_r` | one more than the largest 64-bit integer, 9'223'372'036'854'775'807, which bounds the literal's mantissa -- though a `Rational` holds the value | `formula_rational_literal_out_of_range` | -| `0.0000000000000000001_r` | a denominator of 10^19: the literal's scale, like `from_decimal`'s, stops at 10^18 | `formula_rational_literal_out_of_range` | +| `170'141'183'460'469'231'731'687'303'715'884'105'728_r` | 2^127, one more than the largest integer a `Rational` holds, which bounds the literal's 128-bit mantissa | `formula_rational_literal_out_of_range` | +| `0.000'000'000'000'000'000'000'000'000'000'000'000'001_r` | a denominator of 10^39: the literal's scale, like `from_decimal`'s, stops at 10^38 | `formula_rational_literal_out_of_range` | +| `1e39_r` | 10^39 is an integer no `Rational` holds | `formula_rational_literal_out_of_range` | | `0x1F_r`, `0b101_r` | not a decimal | `formula_rational_literal_not_a_decimal` | | `017_r` | C++ reads a leading zero as octal, so it is not the decimal 17 | `formula_rational_literal_not_a_decimal` | @@ -127,6 +132,62 @@ literal with two dozen places still works when most of them are zeros. The literal is for decimals. A fraction such as one third is still `Rational { 1, 3 }`, or `1_r / 3`. +## Decimal text at run time + +A value that arrives as text -- a CSV import, an instrument's export, a form +field, a configuration value -- is read by `formula::parse_decimal_text`. It +gives the exact value and the places the text was typed to, because a method +that declares a precision needs to know that `"2.400"` is not `"2.4"`: + +```cpp +#include // std::format for Rational::Int +#include + +#include + +std::expected const parsed = + formula::parse_decimal_text("2.400"); +if (!parsed) +{ + std::println("could not read it: {}", formula::describe(parsed.error())); + return; +} +// parsed->value is 12/5, parsed->places is 3 +std::println("{}/{} at {} places", parsed->value.numerator(), parsed->value.denominator(), parsed->places); +``` + +`Rational::from_decimal_text` gives the value alone: +`Rational::from_decimal_text("0.0213")` is 213/10000. The same parser reads +`_r` literals, so text and literal agree on every value. + +The text is an optional `+` or `-`; digits with at most one `.`, and at least +one digit in all (`.5` and `5.` are fine, and so are leading zeros, `007`); +then an optional exponent: `e` or `E`, an optional sign, and at least one +digit. + +| Text | Value | Places | +|---|---|---| +| `"2.400"` | 12/5 | 3 | +| `"2.4"` | 12/5 | 1 | +| `"7"` | 7 | 0 | +| `"1.5e3"` | 1500 | 0 | +| `"15e-1"` | 3/2 | 1 | +| `"-0.0"` | 0 | 1 | + +The places are the digits after the point, minus the exponent, and never below +0. + +Anything else is `ArithmeticError::DomainError`: the empty text, whitespace +anywhere (trim before parsing), a decimal comma (`"2,4"`), any digit separator +(`"1'000"`, `"1_000"`), a second point, hexadecimal, `inf`, `nan`, and an +exponent with no digits (`"1e"`). Text beyond the parser's range is +`ArithmeticError::Overflow`. Most often, its digits, read as an integer, are +above 2^127 − 1 in magnitude, or its scale lies outside 10^-38 to 10^38 once +trailing zeros fold. So `"2.5e-38"` is refused, though 1/(4 · 10^37) would +fit a `Rational`. The comment on `parse_decimal_text` in +`formula-cpp/rational.hpp` lists every cause. Trailing zeros after the point +cost nothing, however many. + ## Exact or nothing `checked_` means exactly one thing in this library: the function returns @@ -251,9 +312,9 @@ the answer. `Rational`'s numerator and denominator are `formula::Int128`, signed 128-bit integers: each holds up to 2^127 − 1, and a numerator down to −2^127 -- up to -39 decimal digits. That is the integer width only. `DecimalPlaces` and the -decimal-place form of `round` stay limited to ±18 places, as `from_decimal`'s -exponent and the `_r` literal's 18 places are; an out-of-range +39 decimal digits. `from_decimal`'s exponent, the `_r` literal and +`parse_decimal_text` reach 10^-38 to 10^38. `DecimalPlaces` and the +decimal-place form of `round` stay limited to ±18 places; an out-of-range `DecimalPlaces` reports `Overflow`, while an out-of-range `SignificantDigits` (fewer than 1) reports `DomainError` -- both mean "argument outside the domain of the operation", but a caller switching on the code should expect diff --git a/docs/numeric-headroom.md b/docs/numeric-headroom.md index b13efa3..6105bea 100644 --- a/docs/numeric-headroom.md +++ b/docs/numeric-headroom.md @@ -423,9 +423,10 @@ different denominator for every point is built to overflow. Only the integer `Rational` stores changed. These stay as they were: -- **Rounding's decimal places**, `from_decimal`'s exponents and the `_r` - literal's 18 places and 64-bit mantissa ([Numbers](numbers.md#limits)). - Widening them is a separate decision. +- **Rounding's decimal places**, which stop at ±18 + ([Numbers](numbers.md#limits)). Widening them is a separate decision. + `from_decimal`'s exponents and the `_r` literal have since widened to + 10^±38 and a 128-bit mantissa. - **The 64-bit fields** of `Unit`, `Band` and `Breakpoint`: `band` and `breakpoint` refuse a `Rational` bound or key that does not fit them. diff --git a/docs/quantities.md b/docs/quantities.md index 7d057da..9efc132 100644 --- a/docs/quantities.md +++ b/docs/quantities.md @@ -75,8 +75,9 @@ that happen to be exercised. ## Declaring a quantity -`formula::Quantity` takes exactly four template parameters, and a quantity is -declared with it in one of two spellings. The alias is the shorter, and the +`formula::Quantity` takes four template parameters, and an optional fifth for +the quantity's [own decimal places](#a-quantitys-own-decimal-places). A quantity +is declared with it in one of two spellings. The alias is the shorter, and the one these guides and the examples use: ```cpp @@ -132,7 +133,7 @@ Rise and ReturnRise share symbol, description and unit: yes | | alias | struct | |---|---|---| | forward declaration | not possible | `struct Rise;` | -| two declarations with all four arguments equal | one type, under two names | two types | +| two declarations with all their arguments equal | one type, under two names | two types | | one tag, another argument different | two types | -- (a struct is its own tag) | | how g++ and clang name it in a diagnostic | `Quantity` | `Rise` | | how cl names it in a diagnostic | usually `Rise`, not always | `Rise` | @@ -142,9 +143,9 @@ Rise and ReturnRise share symbol, description and unit: yes `struct Rise;` for a struct quantity, and must include an alias's declaration. -**Two aliases with all four arguments equal are one type.** Repeating a +**Two aliases with all their arguments equal are one type.** Repeating a declaration -- `using A = formula::Quantity;` -and a `using B` with the same four arguments -- declares one quantity under two +and a `using B` with the same arguments -- declares one quantity under two names, and nothing can object: there is only one type, and naming it twice is not an error anywhere in C++. Give every alias a tag of its own. Two structs never collapse this way, whatever their bases. @@ -182,15 +183,56 @@ but not always -- see Naming a tag after its quantity, `RiseTag`, is what keeps such a diagnostic readable. -**There is no fifth parameter for the dimension.** A `Unit` already carries +**There is no parameter for the dimension.** A `Unit` already carries its dimension (`unit.dimension`), so a separate dimension parameter would state it a second time and let the two disagree. That is not a hypothetical -risk: the five-parameter spelling, with `dim::Mass` paired against +risk: a spelling with a dimension parameter, with `dim::Mass` paired against `unit::Litre`, compiled without a diagnostic on every compiler it was tried on. `Quantity::dimension` is derived from the unit instead, so there is no second place for it to disagree with, and no spelling that lets a caller write the contradiction at all. +### A quantity's own decimal places + +A quantity takes its declared decimal places from its unit, unless it declares +its own. The fifth parameter, a `formula::DecimalPlaces`, defaults to the +unit's places; given, it replaces them for that quantity alone, so two +quantities in one unit can be read to different precision without a second +unit: + +```cpp +inline constexpr formula::Unit Milliampere { .dimension = formula::dim::Current, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, + .symbolText = formula::symbol("mA"), + .decimals = 0 }; + +struct FineCurrent: + formula::Quantity +{ +}; + +struct CoarseCurrent: formula::Quantity +{ +}; +``` + +A quantity's `unit` is its unit with those places in it, so everything that +reads declared places reads the quantity's: 12.34 mA rounds to 12.3 mA through +`checked_round_to_declared` for `FineCurrent` and to 12 mA for `CoarseCurrent`. +`number_text`, `std::format` and a trace pad to, and round an approximation to, +each quantity's own places: padded, 12 mA is written `12.0 mA` for `FineCurrent` +and `12 mA` for `CoarseCurrent`, and 37/3 mA approximated is `≈12.3 mA` and +`≈12 mA`. An exact decimal is always written in full, so 12.34 mA is `12.34 mA` +for both. + +`FineCurrent::unit == Milliampere` is false, since `==` compares every member +of a unit, its decimals among them. Whether two quantities share a unit is what +`formula::same_unit` answers: the same scale as declared, symbol and ASCII key, +whatever their decimals and bounds. `same_unit(FineCurrent::unit, Milliampere)` +and `same_unit(FineCurrent::unit, CoarseCurrent::unit)` are both true. + ## `Describe`, and foreign types Nothing above the metadata layer reads a `Quantity` base directly. Everything @@ -275,6 +317,31 @@ writes an absent `Measured`: a present volume combined with an absent mass: (not measured) ``` +`formula::checked_transform` and `formula::checked_combine` are the +same two functions for a callback that can fail: the callback returns +`std::expected` -- `formula::checked_mul` and its +siblings do -- and the result is a +`std::expected, ArithmeticError>` that carries the callback's +error unchanged. Absence propagates exactly as above, and an absent input +never reaches the callback. Both are `noexcept` when the callback is, so +arithmetic on a measurement can be written under a no-throw rule. A +tenfold dilution of a reading: + +```cpp +auto const diluted = formula::checked_transform( + reading, [](Rational litres) noexcept { return formula::checked_mul(litres, Rational { 10 }); }); +if (!diluted) + return std::unexpected { diluted.error() }; // Overflow, say +``` + +A callback that returns a bare `Rational` does not compile with either: it +cannot fail, so it belongs to `transform` or `combine`. The message begins +`formula: a checked_transform callback must return std::expected` +and says so (`checked_combine`'s names itself, and `combine`). A callback that +cannot be called with the value at all is refused too, with +`formula: a checked_transform callback must be callable with a Rational`, or +`formula: a checked_combine callback must be callable with two Rationals`. + `formula::checked_convert_to` converts a `Measured` into a `Measured` and keeps this rule too -- an absent input converts to an absent output. The two dimensions are checked where the call is written, @@ -355,13 +422,19 @@ substitutes for the other.** `NotChecked` means the unit declares no bounds at all -- there is a value, but nothing to check it against. `NotMeasured` means there is no value in the first place, regardless of whether the unit declares bounds. A reading nobody took and a range nobody declared are -different facts. `test/measured_tests.cpp:206-268` pins all five +different facts. `test/measured_tests.cpp:210-270` pins all five `BoundsCheck` outcomes side by side -- `WithinBounds`, `BelowMinimum` and `AboveMaximum` for present values against a bounded unit, `NotMeasured` for an absent value regardless of whether its unit declares bounds, and `NotChecked` for a present value in a unit (such as `unit::Litre`) that declares no bounds at all. +`formula::checked_within`, which checks a value against limits held at run +time rather than the ones its unit declares +([Dimensions and units](dimensions.md#declared-precision-and-bounds)), takes a +`Measured` too, and keeps the same rule: an absent measurement is +`NotMeasured`, whatever limits it is given. + A present measurement still converts exactly, carrying its quantity's own unit rather than needing one passed alongside it. From the worked example, a rise of 450 m converted to kilometres: @@ -370,16 +443,16 @@ a rise of 450 m converted to kilometres: 450 m converted to km = 0.45 km ``` -The conversion, the rounding and the bounds check each have a throwing twin, -for callers who would only rethrow the error: `formula::convert_to`, -`formula::round_to_declared` and `formula::within_bounds`, which take the -same arguments and return the value itself, and throw `ArithmeticException` -where the `checked_` form returns an error. Absence behaves as above -- an -absent measurement converts and rounds to an absent one and is `NotMeasured` -for its bounds -- and a conversion across dimensions does not compile in -either spelling. The worked example keeps the `checked_` forms, and checks -each result before it reads it, as shown [above](#measurements-that-may-be-absent) -for the conversion. +The conversion, the rounding and the two bounds checks each have a throwing +twin, for callers who would only rethrow the error: `formula::convert_to`, +`formula::round_to_declared`, `formula::within_bounds` and `formula::within`, +which take the same arguments and return the value itself, and throw +`ArithmeticException` where the `checked_` form returns an error. Absence +behaves as above -- an absent measurement converts and rounds to an absent one +and is `NotMeasured` for its bounds -- and a conversion across dimensions does +not compile in either spelling. The worked example keeps the `checked_` forms, +and checks each result before it reads it, as shown +[above](#measurements-that-may-be-absent) for the conversion. ## Limits diff --git a/docs/superpowers/plans/2026-10-05-unit-text-bounds-parsing.md b/docs/superpowers/plans/2026-10-05-unit-text-bounds-parsing.md new file mode 100644 index 0000000..9e5158e --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-unit-text-bounds-parsing.md @@ -0,0 +1,1475 @@ +# Unit Text, Bounds, Per-Quantity Decimals and Run-Time Parsing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close every issue open on 2026-10-05 (#22, #23, #24, #25, #26, #27) in one pull request. + +**Architecture:** Two groups of independent changes, then a merge and the finish. + +- **Units (Lane A, Tasks 1–4).** + - A unit symbol holds 31 bytes; `symbol()` is `consteval`, and run-time text goes through `checked_symbol`. + - A unit carries an ASCII key; a unit whose symbol is not ASCII must declare one, refused at compile time. + - Bounds have an optional end each, and a value is checked against limits held at run time with `checked_within`. + - A quantity can declare its own decimal places, and `same_unit` compares what a unit is. +- **Numbers (Lane B, Tasks 5–6).** + - Decimal text is parsed at run time, by the same parser `_r` uses, now with a 128-bit mantissa; + `from_decimal` reaches 10^±38. + - `checked_transform` and `checked_combine` take callbacks that return `std::expected`. +- **Final tasks (Tasks 7–9).** Merge Lane B; read the guides through once; the finish. + +**Tech Stack:** C++23, header-only; Catch2 3.6 (`STATIC_REQUIRE`), the `test/negative/` harness, the CTest docs checks. + +**Spec:** `docs/superpowers/specs/2026-10-05-unit-text-bounds-parsing-design.md`. Read it before any task. Each task +names the spec section it implements. + +**Order and lanes:** + +- **Lane A, units:** Tasks 1 → 2 → 3 → 4, in `D:\formula-cpp` on branch `feature/unit-text-bounds-parsing`. + - Task 2 uses Task 1's `SymbolError` and `checked_symbol`; Task 4's `same_unit` compares Task 2's key. +- **Lane B, numbers:** Tasks 5 → 6, each in its own agent-owned worktree. + - Their commits go on the branch `feature/unit-text-bounds-parsing-numbers`. The controller creates it at the + commit that adds this plan, and after each Lane B task moves it to that task's head (`git branch -f`). + - Each Lane B task begins with `git reset --hard feature/unit-text-bounds-parsing-numbers` in its fresh worktree. +- **Shared files.** The lanes run at the same time. They share `CHANGELOG.md` and `include/formula-cpp/measured.hpp` + (Lane A adds the `checked_within` overloads after `checked_within_bounds`; Lane B adds `checked_transform` and + `checked_combine` after `combine`), so Task 7's merge is mechanical. +- **After both lanes:** Task 7 merges Lane B into `feature/unit-text-bounds-parsing`, then Tasks 8 → 9 in + `D:\formula-cpp`. + +**Line anchors** were read at `931e81c`. Find each place by the name quoted beside its anchor, never by the number +alone. + +--- + +## Global Constraints + +These bind every task. + +- **C++23, header-only.** Nothing beyond the standard library in `include/`. +- **Worktrees:** + - Lane A and Tasks 7–9 work in `D:\formula-cpp`. + - A Lane B task works only in its own agent-owned worktree. + - No task touches another lane's tree. +- **Gates run from PowerShell.** Bash's pipe mis-encodes `°` and fails `docs.*-output`. + - `$S = C:\Users\c.parpart\AppData\Local\Temp\claude\D--formula-cpp\81d1061b-b25f-4c83-9f67-664a67264017\scratchpad` + - `$T` = the task's tree. +- **Verify, the per-task gate.** Every command must print `ALL OK`: + 1. `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Exclude "^negative\."`: the full cl-debug build, then every test + except the negative ones. + 2. `pwsh -NoProfile -File $S\neg.ps1 -Tree $T -Filter ""`, for a task that adds or touches negative tests. + It runs the negatives matching `` on cl-debug **and** clangcl-debug. +- **Quick loop:** `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter ""`. +- **Run builds and tests in the foreground.** + - Never wait on a background monitor of your own. + - Never redirect a build to `/dev/null`: a `STATIC_REQUIRE` failure is a build error. + - Prove from ctest's count that a filter selected something before trusting it. + - Catch2 splits test filters on commas. +- **Baseline at `931e81c`:** cl-debug passes 1582 of 1582 non-negative tests; 567 negative tests are registered. + Each task reports its total and the difference from the previous task in its lane, and that difference must equal + the number of `TEST_CASE`s and negative tests it added. +- **Invariants (CONTRIBUTING.md), each enforced by a `hygiene.*` test:** + - an SPDX header on every file, and no `NOLINT`; + - core public headers include no ``, ``, ``, `` or `` + (`hygiene.headers`); + - every public `static_assert` message begins `formula: `; + - a new test source is added to `test/CMakeLists.txt` beside its neighbours. +- **Consumer globals.** `test/consumer_globals_tests.cpp` declares a long list of `int` globals (line ~145). No new + parameter or local may reuse one: cl C4459 and g++ `-Wshadow` turn a reuse into a consumer's build error. Names on + the list this plan is tempted by: `value`, `text`, `symbol`, `view`, `low`, `high`, `lo`, `hi`, `bound`, `bounds`, + `left`, `right`, `lhs`, `rhs`, `unit`, `mode`, `offset`, `digits`, `digit`, `numerator`, `denominator`, `sign`, + `scale`, `result`, `error`, `input`, `minimum`, `maximum`, `lower`, `upper`, `character`, `chars`, `length`, + `precision`, `e`, `i`, `n`. The spec's signatures name parameters informally; use the names this plan gives + (`spelling`, `magnitude`, `lowEnd`, `highEnd`, `leftUnit`, `rightUnit`, `measured`, `transformed`). Member names + are not affected. +- **Behaviour rule.** + - Every computation that answers today gives the same answer afterwards, and every trace text, rendered formula + and number text is unchanged (the ASCII key is never displayed). + - The only refusals added are the ones the spec names: `symbol()` given text that is not a constant expression, + a unit whose symbol is not ASCII and that declares no key, and a `checked_transform`/`checked_combine` callback + that does not return `std::expected`. + - Some `Overflow` refusals now answer: `from_decimal` at exponents from 19 to 38 in magnitude, and `_r` literals + wider than 64 bits. A refusal is never turned into a different number. +- **No internal labels in public text.** + - Code, docs, commit messages and the PR never name tasks, lanes, plans, phases, spikes or reviewers, and never + cite local progress notes. + - Every sentence must make sense to a reader who never saw this plan. + - Issue numbers may appear only in the PR body. +- **No third-party standard content.** Fixture values are plainly invented. +- **Style.** + - Do not run clang-format on existing files; match the surrounding style by hand. + - Every new public entity and member gets a Doxygen `///` comment. +- **Printing** is `std::print` / `std::println` only. Never `printf`, `puts` or iostream. +- **Error handling.** Check every `std::expected` result before use. Never unwrap unchecked, and never switch to a + throwing form to shorten code. +- **Newest GCC only** (g++-14). No workaround for an older compiler. +- **Commits.** Conventional style (`feat(unit): …`, `fix: …`, `test: …`, `docs: …`). Every message ends with: + + ``` + Signed-off-by: Christian Parpart + ``` + + Commit with `git commit -F `; a here-string passed to `-F -` does not work in PowerShell. +- **CHANGELOG.md:** + - Entries go under `## [Unreleased]` (it exists, empty, at the top), in `### Added` / `### Changed` / `### Fixed` + subsections. + - A breaking change says so in its first words: `**Breaking:** …`. + +## Review Focus + +The five inputs this spec implies but no obvious test reaches, most likely to bite first. Each one's test is in the +task named. + +1. **A symbol of exactly 31 bytes that ends in a multi-byte UTF-8 character**, built by `symbol()` and by + `checked_symbol`. It fits and is kept whole; 32 bytes is refused, never cut inside the character. Task 1. +2. **A unit whose symbol is ASCII but whose declared key is not** (`asciiText = "µm"` on a unit spelled `um`). It is + refused like a missing key: the key is what a serialiser trusts. Task 2. +3. **A trace quotient where only the divisor has a key** (`kg` over `µm`): the quotient's key is `kg/um`, built from + `view_ascii` of both, and the display symbol stays `kg/µm`. Task 2. +4. **A one-sided limit at exactly its end**, and an empty limit pair: `checked_within(0, 0, nullopt)` is + `WithinBounds`; `checked_within(x, nullopt, nullopt)` is `NotChecked`, never `WithinBounds`. Task 3. +5. **Text the parser must not half-accept:** + - `"1e"`, `"1e+"`, `"."`, `"+"`, `"-"`, `"1.2.3"` and `"1'000"` (accepted by `_r`, refused at run time); + - a spelling with a thousand leading fractional zeros before a digit (`Overflow`, not a hang or a wrong value); + - `"-0.0"` (zero, places 1). + Task 5. + +## Execution + +- **Briefs and reports:** + - The controller writes each task's brief to + `D:\formula-cpp\.superpowers\sdd\2026-10-05-unit-text-bounds-parsing\task-N-brief.md` (`.superpowers/` is + git-ignored). + - The implementer writes its report beside it, as `task-N-report.md`. + - A Lane B implementer that cannot write outside its worktree writes the report to `.superpowers/task-N-report.md` + inside its worktree and says so in its reply. + - A report states: the commits; the test total and its delta; each rule in *Global Constraints* the task touched, + and how it was kept; anything it could not do. +- **Agents.** Every task is implemented by `sdd-implementer` and reviewed by `sdd-reviewer`. A fix round goes back to + `sdd-implementer`. Task 7's merge is run by the controller; an `sdd-implementer` resolves any conflict that is not + mechanical. +- **Negative tests**, where a task adds one: + 1. Add `test/negative/.cpp`, plus `formula_add_negative_test( "" …)` in + `test/CMakeLists.txt`, beside its neighbours. + 2. Register it first with a deliberately wrong expected text, and watch it fail. + 3. Register the right text, and watch it pass, on cl-debug and clangcl-debug. + 4. Delete the guard it pins, confirm the case compiles, then restore the guard. + +--- + +## Lane A — units (#27, #22, #25, #26) + +Lane A works in `D:\formula-cpp` on branch `feature/unit-text-bounds-parsing`, Tasks 1 → 2 → 3 → 4. Throughout the +lane, `$T = D:\formula-cpp`. + +--- + +### Task 1: Symbol capacity and building a symbol at run time (#27) + +Spec §2.1. + +**Files:** +- Modify: `include/formula-cpp/dimension.hpp` (`SymbolCapacity` :189, `formula_unit_symbol_too_long` :220, + `symbol()` :230) +- Modify: `test/unit_tests.cpp` (the symbol tests near :129) +- Modify: `test/number_text_tests.cpp` (`Widest` :54) +- Modify: `test/negative/unit_symbol_too_long.cpp`, `test/negative/base_dimension_name_too_long.cpp` +- Create: `test/negative/unit_symbol_from_runtime_text.cpp` +- Modify: `test/CMakeLists.txt` (negatives near :329 and :369) +- Modify: `docs/dimensions.md` (capacity at :428 and :500–508) +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Produces (used by Task 2): + - `inline constexpr std::size_t SymbolCapacity = 32;` + - `enum class SymbolError : std::uint8_t { TooLong, EmbeddedNull, NotAscii };` + - `[[nodiscard]] constexpr std::string_view describe(SymbolError symbolError) noexcept;` + - `[[nodiscard]] constexpr std::expected checked_symbol(std::string_view spelling) noexcept;` + - `[[nodiscard]] consteval Symbol symbol(char const* spelling) noexcept;` + +- [ ] **Step 1: Write the failing tests** in `test/unit_tests.cpp`, beside the existing symbol tests. Use the file's + existing `using` declarations; add `#include ` and `` if absent. + +```cpp +TEST_CASE("checked_symbol: run-time text that fits is kept byte for byte", "[unit][symbol]") +{ + // µmol/(L·min·kg): 18 bytes of UTF-8. + constexpr std::string_view compound = "\xc2\xb5mol/(L\xc2\xb7min\xc2\xb7kg)"; + STATIC_REQUIRE(compound.size() == 18); + constexpr std::expected built = formula::checked_symbol(compound); + STATIC_REQUIRE(built.has_value()); + STATIC_REQUIRE(formula::view(*built) == compound); + + // 31 bytes, the most that fits, ending in a two-byte character: kept whole. + constexpr std::string_view widest = "abcdefghijklmnopqrstuvwxyz012\xc2\xb5"; + STATIC_REQUIRE(widest.size() == formula::SymbolCapacity - 1); + constexpr std::expected widestBuilt = formula::checked_symbol(widest); + STATIC_REQUIRE(widestBuilt.has_value()); + STATIC_REQUIRE(formula::view(*widestBuilt) == widest); + + constexpr std::expected empty = formula::checked_symbol(""); + STATIC_REQUIRE(empty.has_value()); + STATIC_REQUIRE(formula::view(*empty).empty()); + + // The same at run time, from text the compiler cannot see. + std::string const fromCatalogue { compound }; + std::expected const atRunTime = formula::checked_symbol(fromCatalogue); + REQUIRE(atRunTime.has_value()); + REQUIRE(formula::view(*atRunTime) == compound); +} + +TEST_CASE("checked_symbol: text that does not fit, or holds a NUL, is refused", "[unit][symbol]") +{ + constexpr std::string_view tooLong = "abcdefghijklmnopqrstuvwxyz0123\xc2\xb5"; // 32 bytes + STATIC_REQUIRE(tooLong.size() == formula::SymbolCapacity); + STATIC_REQUIRE(formula::checked_symbol(tooLong).error() == formula::SymbolError::TooLong); + + constexpr std::string_view withNull { "mg\0L", 4 }; + STATIC_REQUIRE(formula::checked_symbol(withNull).error() == formula::SymbolError::EmbeddedNull); +} + +TEST_CASE("describe(SymbolError) names each refusal", "[unit][symbol]") +{ + STATIC_REQUIRE(formula::describe(formula::SymbolError::TooLong) + == "the symbol does not fit SymbolCapacity bytes, terminator included"); + STATIC_REQUIRE(formula::describe(formula::SymbolError::EmbeddedNull) == "the symbol contains a NUL byte"); + STATIC_REQUIRE(formula::describe(formula::SymbolError::NotAscii) + == "the symbol holds a byte outside printable ASCII"); +} + +TEST_CASE("symbol(): a compound UTF-8 laboratory unit fits", "[unit][symbol]") +{ + constexpr formula::Symbol compound = formula::symbol("\xc2\xb5mol/(L\xc2\xb7min\xc2\xb7kg)"); + STATIC_REQUIRE(formula::view(compound).size() == 18); +} +``` + +- [ ] **Step 2: Run them and watch them fail.** Quick loop with `-Filter "symbol"`. Expected: a build error naming + `checked_symbol` / `SymbolError`. + +- [ ] **Step 3: Implement in `dimension.hpp`.** + - `SymbolCapacity` becomes 32. Its comment: enough for compound laboratory units such as `µmol/(L·min·kg)` + (18 bytes); a literal that does not fit is a compile error (`symbol()`), run-time text is refused by + `checked_symbol`, never truncated. + - `symbol()` becomes `consteval`. Its comment says a run-time spelling goes through `checked_symbol`. + - `formula_unit_symbol_too_long`'s comment: its body is reached only during constant evaluation, where reaching + a non-`constexpr` function is the refusal; `symbol()` is `consteval`, so no run-time call exists. Keep the body. + - Add, after `view(Symbol&&)`: + +```cpp +/// Why `checked_symbol` or `checked_ascii_symbol` refused a spelling. +enum class SymbolError : std::uint8_t +{ + /// The spelling does not fit `SymbolCapacity` bytes, terminator included. + TooLong, + /// The spelling contains a NUL byte, which would end the stored symbol early. + EmbeddedNull, + /// The spelling holds a byte outside printable ASCII (0x20 to 0x7E); only `checked_ascii_symbol` asks. + NotAscii, +}; + +/// @p symbolError in prose, for an error message. +[[nodiscard]] constexpr std::string_view describe(SymbolError symbolError) noexcept +{ + switch (symbolError) + { + case SymbolError::TooLong: return "the symbol does not fit SymbolCapacity bytes, terminator included"; + case SymbolError::EmbeddedNull: return "the symbol contains a NUL byte"; + case SymbolError::NotAscii: return "the symbol holds a byte outside printable ASCII"; + } + return "unknown symbol error"; +} + +/// Builds a Symbol from run-time text -- a catalogue row, a configuration file -- byte for byte. Refuses text that +/// does not fit or that holds a NUL; never truncates, never aborts. UTF-8 is not validated: a symbol is bytes. +[[nodiscard]] constexpr std::expected checked_symbol(std::string_view spelling) noexcept +{ + if (spelling.size() + 1 > SymbolCapacity) + return std::unexpected { SymbolError::TooLong }; + Symbol built {}; + for (std::size_t characterIndex = 0; characterIndex < spelling.size(); ++characterIndex) + { + if (spelling[characterIndex] == '\0') + return std::unexpected { SymbolError::EmbeddedNull }; + built.characters[characterIndex] = spelling[characterIndex]; + } + return built; +} +``` + + Add `#include ` if `dimension.hpp` lacks it. + +- [ ] **Step 4: Move the tests that pin the old capacity.** + - `test/number_text_tests.cpp` `Widest`: fill all 32 bytes (`'a'` … `'z'`, `'0'` … `'5'`), so it still tests a + symbol with no terminator at the full capacity. Adjust any expected text in that file that spells the 16 + letters. + - `test/unit_tests.cpp` :129, :135, :367 use `SymbolCapacity` by name; check each still states what it claims. + - `test/negative/unit_symbol_too_long.cpp`: the literal becomes `"0123456789abcdef0123456789abcdef"` (32 bytes), + and the comment says 32 bytes, one more than the 31 usable. + - `test/negative/base_dimension_name_too_long.cpp`: the name becomes `"AcmeLoyaltyProgrammePointsLedger"` + (32 bytes); fix its comment the same way. + - New `test/negative/unit_symbol_from_runtime_text.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// symbol() is consteval: text that only exists at run time cannot reach it, so a symbol too long for +// SymbolCapacity can never abort a running program. Run-time text goes through checked_symbol. This must not compile. +#include + +int main(int argumentCount, char** arguments) +{ + formula::Symbol const fromRunTime = formula::symbol(arguments[argumentCount - 1]); + return fromRunTime.characters[0]; +} +``` + + Register it as `formula_add_negative_test(unit_symbol_from_runtime_text "")`, where `` is a phrase + both cl and clang-cl print for a non-constant call of a `consteval` function. Find it by building the case on both + (cl: C7595 "call to immediate function is not a constant expression"; clang-cl: "call to consteval function … + is not a constant expression"); `is not a constant expression` is the expected common part. Record both + diagnostics in the report. + +- [ ] **Step 5: Docs and CHANGELOG.** + - `docs/dimensions.md`: the capacity is 32 bytes including the terminator, 31 usable, at both places; add one + paragraph: a symbol from run-time text is built with `checked_symbol`, which returns `SymbolError::TooLong` or + `EmbeddedNull` instead of aborting, and `symbol()` accepts only a constant expression. + - CHANGELOG `### Changed`: `**Breaking:** \`symbol()\` is \`consteval\`: …` and the capacity entry; `### Added`: + `checked_symbol` and `SymbolError`. + +- [ ] **Step 6: Verify.** Gate 1, and gate 2 with `-Filter "unit_symbol|base_dimension_name"`. Expected delta: +4 + tests, +1 negative. + +- [ ] **Step 7: Commit** `feat(unit): symbols of 31 bytes, built at run time without aborting`. + +--- + +### Task 2: A stable ASCII key per unit (#22) + +Spec §2.2. + +**Files:** +- Modify: `include/formula-cpp/unit.hpp` (`Unit` :67, the four built-ins `PerMille` :152, `Micrometre` :202, + `Celsius` :313, `Fahrenheit` :325; after `RequireNamedScaledScalar` :569) +- Modify: every header asserting `detail::RequireNamedScaledScalar` (24 sites, listed in Step 4) +- Modify: `include/formula-cpp/quantity.hpp` (`RequireDescribedUnitNamesItsScale` :227, `RequireDescribed` :249) +- Modify: `include/formula-cpp/trace.hpp` (`unit_quotient` :2853) +- Modify: `test/unit_tests.cpp`, and the test that covers `unit_quotient` (find it with `git grep unit_quotient test`) +- Create: `test/negative/unit_without_ascii_key_{measured,constant,snap,var}.cpp` +- Modify: `test/CMakeLists.txt`, `README.md`, `docs/dimensions.md`, `CHANGELOG.md` + +**Interfaces:** +- Consumes: `checked_symbol`, `SymbolError` (Task 1). +- Produces (used by Task 4): + - `Symbol Unit::asciiText {};`, directly after `symbolText` + - `[[nodiscard]] constexpr std::string_view view_ascii(Unit const& unitValue) noexcept;` + and `std::string_view view_ascii(Unit&&) = delete;` + - `[[nodiscard]] constexpr bool has_ascii_key(Unit const& unitValue) noexcept;` + - `[[nodiscard]] constexpr std::expected checked_ascii_symbol(std::string_view spelling) noexcept;` + - `template struct detail::RequireAsciiKey;` with `static constexpr bool value = true;` + +- [ ] **Step 1: Write the failing tests** in `test/unit_tests.cpp`: + +```cpp +TEST_CASE("view_ascii: a unit's key, or its symbol when that is ASCII", "[unit][ascii]") +{ + STATIC_REQUIRE(formula::view_ascii(formula::unit::Millimetre) == "mm"); + STATIC_REQUIRE(formula::view_ascii(formula::unit::PerMille) == "permille"); + STATIC_REQUIRE(formula::view_ascii(formula::unit::Micrometre) == "um"); + STATIC_REQUIRE(formula::view_ascii(formula::unit::Celsius) == "degC"); + STATIC_REQUIRE(formula::view_ascii(formula::unit::Fahrenheit) == "degF"); + STATIC_REQUIRE(formula::view_ascii(formula::coherent(formula::dim::Mass)).empty()); + // The display symbol is unchanged. + STATIC_REQUIRE(formula::view(formula::unit::Micrometre.symbolText) == "\xc2\xb5m"); +} + +TEST_CASE("has_ascii_key: false for a non-ASCII symbol without a key, and for a non-ASCII key", "[unit][ascii]") +{ + constexpr formula::Unit MicrogramPerLitreUnkeyed { .dimension = formula::dim::Mass / formula::dim::Volume, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1'000'000, + .symbolText = formula::symbol("\xc2\xb5g/L") }; + STATIC_REQUIRE(!formula::has_ascii_key(MicrogramPerLitreUnkeyed)); + + constexpr formula::Unit MicrogramPerLitre { .dimension = formula::dim::Mass / formula::dim::Volume, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1'000'000, + .symbolText = formula::symbol("\xc2\xb5g/L"), + .asciiText = formula::symbol("ug/L") }; + STATIC_REQUIRE(formula::has_ascii_key(MicrogramPerLitre)); + STATIC_REQUIRE(formula::view_ascii(MicrogramPerLitre) == "ug/L"); + + // An ASCII symbol with a key that is not ASCII is refused like a missing key: the key is what a serialiser trusts. + constexpr formula::Unit BadKey { .dimension = formula::dim::Length, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1'000'000, + .symbolText = formula::symbol("um"), + .asciiText = formula::symbol("\xc2\xb5m") }; + STATIC_REQUIRE(!formula::has_ascii_key(BadKey)); + STATIC_REQUIRE(formula::has_ascii_key(formula::unit::Millimetre)); +} + +TEST_CASE("checked_ascii_symbol: printable ASCII only", "[unit][ascii]") +{ + STATIC_REQUIRE(formula::view(*formula::checked_ascii_symbol("ug/L")) == "ug/L"); + STATIC_REQUIRE(formula::checked_ascii_symbol("\xc2\xb5g/L").error() == formula::SymbolError::NotAscii); + STATIC_REQUIRE(formula::checked_ascii_symbol("tab\there").error() == formula::SymbolError::NotAscii); + STATIC_REQUIRE(formula::checked_ascii_symbol("abcdefghijklmnopqrstuvwxyz012345").error() + == formula::SymbolError::TooLong); + STATIC_REQUIRE(formula::checked_ascii_symbol(std::string_view { "u\0g", 3 }).error() + == formula::SymbolError::EmbeddedNull); +} +``` + + Where a `STATIC_REQUIRE` dereferences an `expected`, the dereference is checked by the same expression's + constant evaluation (an empty `expected` fails to compile); keep it, but do not copy that idiom into run-time code. + Adjust unit names (`Millimetre`, the volume dimension spelling) to the header's real names. + + The quotient test, beside the existing `unit_quotient` tests: + +```cpp +TEST_CASE("unit_quotient: a quotient of keyed units carries a quotient key", "[trace][unit][ascii]") +{ + std::optional const perSecond = formula::detail::unit_quotient(formula::unit::Micrometre, + formula::unit::Second); + REQUIRE(perSecond.has_value()); + REQUIRE(formula::view(perSecond->symbolText) == "\xc2\xb5m/s"); + REQUIRE(formula::view_ascii(*perSecond) == "um/s"); + + std::optional const kilogramPerMicrometre = + formula::detail::unit_quotient(formula::unit::Kilogram, formula::unit::Micrometre); + REQUIRE(kilogramPerMicrometre.has_value()); + REQUIRE(formula::view(kilogramPerMicrometre->symbolText) == "kg/\xc2\xb5m"); + REQUIRE(formula::view_ascii(*kilogramPerMicrometre) == "kg/um"); + + // Neither operand has a key of its own: the quotient has none either, and view_ascii reads its symbol. + std::optional const metrePerSecond = formula::detail::unit_quotient(formula::unit::Metre, + formula::unit::Second); + REQUIRE(metrePerSecond.has_value()); + REQUIRE(formula::view(metrePerSecond->asciiText).empty()); + REQUIRE(formula::view_ascii(*metrePerSecond) == "m/s"); +} +``` + +- [ ] **Step 2: Run them and watch them fail** (quick loop, `-Filter "ascii"`). + +- [ ] **Step 3: Implement in `unit.hpp`.** + - `Unit` gains, after `symbolText`: + +```cpp + /// A stable ASCII key for serialising the unit -- a JSON annotation, a database column, a client's choice -- that + /// stays the same when `symbolText` is restyled. Empty when the symbol is its own key; required when the symbol + /// is not printable ASCII (see `RequireAsciiKey`). Never displayed: read it with `view_ascii`. + Symbol asciiText {}; +``` + + - After `checked_symbol` is visible (top of `unit.hpp`, after the includes, in `namespace formula`): + +```cpp +namespace detail +{ + /// Whether every byte of @p spelling is printable ASCII, 0x20 to 0x7E. The empty text is. + [[nodiscard]] constexpr bool is_printable_ascii(std::string_view spelling) noexcept + { + for (char const byteAt: spelling) + if (static_cast(byteAt) < 0x20U || static_cast(byteAt) > 0x7EU) + return false; + return true; + } +} // namespace detail + +/// Builds an ASCII key from run-time text: `checked_symbol`'s refusals, and `SymbolError::NotAscii` for any byte +/// outside printable ASCII. +[[nodiscard]] constexpr std::expected checked_ascii_symbol(std::string_view spelling) noexcept +{ + // checked_symbol first: a NUL is below 0x20, and must be reported as EmbeddedNull, not NotAscii. + std::expected const built = checked_symbol(spelling); + if (!built) + return std::unexpected { built.error() }; + if (!detail::is_printable_ascii(spelling)) + return std::unexpected { SymbolError::NotAscii }; + return *built; +} +``` + + - After `struct Unit`: + +```cpp +/// The unit's serialising key: `asciiText` when one is declared, otherwise `symbolText`. Stable across restyling of +/// the display symbol. A unit that a template takes is guaranteed printable ASCII here (`RequireAsciiKey`); for a unit +/// built at run time, ask `has_ascii_key` first. +[[nodiscard]] constexpr std::string_view view_ascii(Unit const& unitValue) noexcept +{ + std::string_view const declaredKey = view(unitValue.asciiText); + return declaredKey.empty() ? view(unitValue.symbolText) : declaredKey; +} + +/// Deleted: the view would point into a destroyed temporary, as for `view(Symbol&&)`. +std::string_view view_ascii(Unit&&) = delete; + +/// Whether @p unitValue has a printable-ASCII key: its declared `asciiText`, or, when none is declared, its symbol. +[[nodiscard]] constexpr bool has_ascii_key(Unit const& unitValue) noexcept +{ + return detail::is_printable_ascii(view_ascii(unitValue)); +} +``` + + - In `namespace detail`, after `RequireNamedScaledScalar`: + +```cpp + /// Fails to compile when @p U's symbol is not printable ASCII and it declares no ASCII key (`has_ascii_key`). + /// Asserted wherever `RequireNamedScaledScalar` is, so every unit a quantity, constant, rounding or table uses + /// can be serialised by `view_ascii`. Write `::value`, as there. + template + struct RequireAsciiKey + { + static_assert(has_ascii_key(U), + "formula: a unit whose symbol is not ASCII must declare an ASCII key (asciiText), for " + "example .asciiText = formula::symbol(\"ug/L\") for ug/L written with a micro sign; the unit " + "appears in this diagnostic as the template argument of RequireAsciiKey"); + + /// Always `true` once reached -- the `static_assert` above already failed compilation otherwise. + static constexpr bool value = true; + }; +``` + + - The four built-ins gain `.asciiText = symbol("permille")`, `symbol("um")`, `symbol("degC")`, + `symbol("degF")`, written directly after `.symbolText` (designated initialisers must keep member order). + +- [ ] **Step 4: Assert the check beside every `RequireNamedScaledScalar`.** After each of these lines add the same + line with `RequireAsciiKey`, same argument (`git grep -n "RequireNamedScaledScalar<" include` must list exactly + these, and afterwards `RequireAsciiKey<` the same 23 plus quantity.hpp's): + `binning.hpp:146`, `conformity.hpp:349`, `critical_value.hpp:382`, `curve.hpp:111`, `escape.hpp:90`, + `expression.hpp:53`, `expression.hpp:79`, `lookup.hpp:769`, `lookup.hpp:770`, `lookup.hpp:1177`, + `lookup.hpp:1812`, `lookup.hpp:1813`, `method.hpp:1085`, `observations.hpp:61`, `opaque.hpp:1030`, + `overlay.hpp:699`, `rounded_root.hpp:284`, `rounding_node.hpp:52`, `rounding_node.hpp:79`, `series.hpp:73`, + `series.hpp:183`, `series.hpp:500`, `snap.hpp:172`. + + In `quantity.hpp`, add beside `RequireDescribedUnitNamesItsScale`: + +```cpp + /// `RequireAsciiKey` of a described type's unit, asked only once the type is described, as above. + template > + struct RequireDescribedUnitHasAsciiKey: std::true_type + { + }; + + template + struct RequireDescribedUnitHasAsciiKey: RequireAsciiKey::unit> + { + }; +``` + + and `static_assert(detail::RequireDescribedUnitHasAsciiKey::value);` after the scale check in + `RequireDescribed`; extend its comment ("… or declares it in a unit whose symbol is not ASCII and that has no ASCII + key"). + +- [ ] **Step 5: The quotient key in `unit_quotient` (`trace.hpp`).** When either operand's `asciiText` is not empty, + build the quotient's `asciiText` from `view_ascii(over)`, `/`, `view_ascii(under)`, bracketing the divisor when + `compound_unit_symbol(view_ascii(under))` holds. Refuse (`std::nullopt`) when either key contains `/` or the key + does not fit `SymbolCapacity`, exactly as the symbol is refused. Write both through one local helper lambda or + function that appends `over`, `/`, an optionally bracketed `under` into a `Symbol`, so the symbol and the key use + one piece of code; the existing capacity check moves into it. + +- [ ] **Step 6: Every unit in the repository passes.** Build everything (gate 1). Any test, example or guide unit + with a non-ASCII symbol and no key now fails to compile with the new message: give it a key (`"ug/L"`, `"degC"`, + and so on), never weaken the check. List each one you keyed in the report. + +- [ ] **Step 7: Negative tests.** Copy each `test/negative/scaled_scalar_unit_without_symbol_{measured,constant,snap,var}.cpp` + to `unit_without_ascii_key_{…}.cpp`, replacing the scaled scalar unit with: + +```cpp +inline constexpr formula::Unit MicrogramPerLitre { .dimension = formula::dim::Mass / formula::dim::Volume, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1'000'000, + .symbolText = formula::symbol("\xc2\xb5g/L") }; +``` + + (adjusting the dimension spelling to the header's), and each header comment to say a non-ASCII unit without a key + must not compile. Register each with `"formula: a unit whose symbol is not ASCII must declare an ASCII key"` and the + same `EXPECT_COUNT`/`REJECT` options the copied case uses. + +- [ ] **Step 8: Docs and CHANGELOG.** + - `README.md`, in the units part: one short paragraph: serialise a unit with `formula::view_ascii(unit)`, its + stable ASCII key, not with its display symbol; a unit whose symbol is not ASCII declares the key as + `.asciiText`. + - `docs/dimensions.md`: `asciiText` in the `Unit` fields table (:124–135); a short section on the key, + `view_ascii`, `has_ascii_key`, `checked_ascii_symbol`, the refusal, and the four built-in keys. + - CHANGELOG `### Added`: the key and its functions; `### Changed`: `**Breaking:** a unit whose symbol is not + ASCII must declare an ASCII key …`. + +- [ ] **Step 9: Verify.** Gate 1; gate 2 with `-Filter "unit_without_ascii_key|scaled_scalar_unit_without_symbol"`. + Expected delta: +4 tests, +4 negatives. + +- [ ] **Step 10: Commit** `feat(unit): a stable ASCII key per unit`. + +--- + +### Task 3: One-sided bounds, and bounds known at run time (#25) + +Spec §2.3. + +**Files:** +- Modify: `include/formula-cpp/unit.hpp` (`Bounds` :28, `bounds()` :46, `checked_within_bounds` :701) +- Modify: `include/formula-cpp/measured.hpp` (after the `Measured` `checked_within_bounds` :229, and after + `within_bounds` :257) +- Modify: `test/unit_tests.cpp` (:801–802, :936–988), `test/measured_tests.cpp` +- Modify: `docs/dimensions.md` (:291–340), `CHANGELOG.md` + +**Interfaces:** +- Produces: + - `Bounds { bool lowPresent = false; bool highPresent = false; std::int64_t lowNumerator = 0, lowDenominator = 1, highNumerator = 0, highDenominator = 1; }` + - `constexpr Bounds bounds(std::int64_t lowNumerator, std::int64_t lowDenominator, std::int64_t highNumerator, std::int64_t highDenominator) noexcept;` (unchanged) + - `constexpr Bounds at_least(std::int64_t lowNumerator, std::int64_t lowDenominator) noexcept;` + - `constexpr Bounds at_most(std::int64_t highNumerator, std::int64_t highDenominator) noexcept;` + - `constexpr std::expected checked_within(Rational magnitude, std::optional lowEnd, std::optional highEnd) noexcept;` + - `constexpr BoundsCheck within(Rational magnitude, std::optional lowEnd, std::optional highEnd);` (throws) + - `template constexpr std::expected checked_within(Measured measured, std::optional lowEnd, std::optional highEnd) noexcept;` + - `template constexpr BoundsCheck within(Measured measured, std::optional lowEnd, std::optional highEnd);` (throws) + +- [ ] **Step 1: Write the failing tests** in `test/unit_tests.cpp`: + +```cpp +TEST_CASE("checked_within: either end, both, or neither", "[unit][bounds]") +{ + using formula::BoundsCheck; + using formula::Rational; + constexpr std::optional none {}; + STATIC_REQUIRE(*formula::checked_within(Rational { 5 }, Rational { 0 }, none) == BoundsCheck::WithinBounds); + STATIC_REQUIRE(*formula::checked_within(Rational { -1 }, Rational { 0 }, none) == BoundsCheck::BelowMinimum); + STATIC_REQUIRE(*formula::checked_within(Rational { 0 }, Rational { 0 }, none) == BoundsCheck::WithinBounds); + STATIC_REQUIRE(*formula::checked_within(Rational { 21 }, none, Rational { 20 }) == BoundsCheck::AboveMaximum); + STATIC_REQUIRE(*formula::checked_within(Rational { 20 }, none, Rational { 20 }) == BoundsCheck::WithinBounds); + STATIC_REQUIRE(*formula::checked_within(Rational { 7 }, Rational { 0 }, Rational { 20 }) == BoundsCheck::WithinBounds); + STATIC_REQUIRE(*formula::checked_within(Rational { 7 }, none, none) == BoundsCheck::NotChecked); + STATIC_REQUIRE(formula::checked_within(Rational { 7 }, Rational { 20 }, Rational { 0 }).error() + == formula::ArithmeticError::DomainError); + REQUIRE(formula::within(Rational { 7 }, none, Rational { 5 }) == BoundsCheck::AboveMaximum); + REQUIRE_THROWS_AS(formula::within(Rational { 7 }, Rational { 9 }, Rational { 5 }), formula::ArithmeticException); +} + +TEST_CASE("checked_within_bounds: a unit declared with at_least or at_most", "[unit][bounds]") +{ + using formula::BoundsCheck; + using formula::Rational; + constexpr formula::Unit NonNegative { .dimension = formula::dim::Scalar, + .symbolText = formula::symbol("x"), + .bounds = formula::at_least(0, 1) }; + constexpr formula::Unit AtMostTwenty { .dimension = formula::dim::Scalar, + .symbolText = formula::symbol("y"), + .bounds = formula::at_most(20, 1) }; + STATIC_REQUIRE(NonNegative.bounds.lowPresent && !NonNegative.bounds.highPresent); + STATIC_REQUIRE(*formula::checked_within_bounds(Rational { 1'000'000 }, NonNegative) == BoundsCheck::WithinBounds); + STATIC_REQUIRE(*formula::checked_within_bounds(Rational { -1, 2 }, NonNegative) == BoundsCheck::BelowMinimum); + STATIC_REQUIRE(*formula::checked_within_bounds(Rational { -1'000'000 }, AtMostTwenty) == BoundsCheck::WithinBounds); + STATIC_REQUIRE(*formula::checked_within_bounds(Rational { 41, 2 }, AtMostTwenty) == BoundsCheck::AboveMaximum); +} +``` + + In `test/measured_tests.cpp`, using the file's existing measured quantity: + +```cpp +TEST_CASE("checked_within on a measurement: absent is NotMeasured", "[measured][bounds]") +{ + // is the file's existing scalar or mass quantity. + constexpr formula::Measured absentReading {}; + STATIC_REQUIRE(*formula::checked_within(absentReading, formula::Rational { 0 }, std::nullopt) + == formula::BoundsCheck::NotMeasured); + constexpr formula::Measured present { formula::Rational { 3 } }; + STATIC_REQUIRE(*formula::checked_within(present, formula::Rational { 0 }, std::nullopt) + == formula::BoundsCheck::WithinBounds); + REQUIRE(formula::within(present, std::nullopt, formula::Rational { 2 }) == formula::BoundsCheck::AboveMaximum); +} +``` + +- [ ] **Step 2: Run them and watch them fail** (quick loop, `-Filter "bounds|within"`). + +- [ ] **Step 3: Implement in `unit.hpp`.** + - `Bounds`: replace `present` with + +```cpp + /// Whether a minimum was declared. `false` for a unit with no lower limit. + bool lowPresent = false; + /// Whether a maximum was declared. `false` for a unit with no upper limit. + bool highPresent = false; +``` + + and update the struct comment: either end, both, or neither. + - `bounds()` returns `{ true, true, … }`; add `at_least` (`{ true, false, lowNumerator, lowDenominator, 0, 1 }`) and + `at_most` (`{ false, true, 0, 1, highNumerator, highDenominator }`), each with a `///` line. + - Before `checked_within_bounds`, add `checked_within` (needs ``): + +```cpp +/// Checks @p magnitude against limits held at run time -- a specification row, a catalogue entry -- either of which +/// may be absent. Both ends are inclusive. With no end there is nothing to check (`NotChecked`, never +/// `WithinBounds`); a lower end above the upper one is a malformed pair of limits, refused as `DomainError`. +[[nodiscard]] constexpr std::expected checked_within( + Rational magnitude, std::optional lowEnd, std::optional highEnd) noexcept +{ + if (!lowEnd && !highEnd) + return BoundsCheck::NotChecked; + if (lowEnd && highEnd && *lowEnd > *highEnd) + return std::unexpected { ArithmeticError::DomainError }; + if (lowEnd && magnitude < *lowEnd) + return BoundsCheck::BelowMinimum; + if (highEnd && magnitude > *highEnd) + return BoundsCheck::AboveMaximum; + return BoundsCheck::WithinBounds; +} + +/// @throws ArithmeticException when the checked form would report an error. +[[nodiscard]] constexpr BoundsCheck within(Rational magnitude, std::optional lowEnd, + std::optional highEnd) +{ + return detail::or_throw(checked_within(magnitude, lowEnd, highEnd)); +} +``` + + - `checked_within_bounds(Rational, Unit)` builds each declared end with `Rational::make` (an error returns as + today), leaves an undeclared one `std::nullopt`, and returns `checked_within(magnitude, lowEnd, highEnd)`. Keep + its comment about a malformed unit, moved to say where the refusal now happens. + +- [ ] **Step 4: `measured.hpp`.** After the `Measured` `checked_within_bounds`: + +```cpp +/// Checks a measurement against limits held at run time, as `checked_within` on a `Rational`; an absent +/// measurement is `BoundsCheck::NotMeasured`. +template +[[nodiscard]] constexpr std::expected checked_within( + Measured measured, std::optional lowEnd, std::optional highEnd) noexcept +{ + if (measured.is_absent()) + return BoundsCheck::NotMeasured; + return checked_within(measured.value(), lowEnd, highEnd); +} +``` + + and after `within_bounds`, the throwing `within(Measured, …)` built on `detail::or_throw`, with the same comment + shape as `within_bounds`. + +- [ ] **Step 5: Move the old readers.** `test/unit_tests.cpp:801–802` read `lowPresent`/`highPresent`; the positional + `Bounds` at :976 and :986 become designated (`.bounds = { .lowPresent = true, .highPresent = true, .lowNumerator = 100, … }`) + keeping each test's meaning (the `false` one has neither end). `git grep -n "\.present\b"` must find nothing in + `include`, `test`, `examples`, `tools` or `docs` (outside `docs/superpowers`). + +- [ ] **Step 6: Docs and CHANGELOG.** `docs/dimensions.md` bounds section: per-end presence, `at_least`/`at_most`, and + `checked_within` for limits read at run time (a one-sided example: a lower limit from a catalogue row). CHANGELOG + `### Added`: `at_least`, `at_most`, `checked_within`, `within`; `### Changed`: `**Breaking:** \`Bounds::present\` is + replaced by \`lowPresent\` and \`highPresent\` …`. + +- [ ] **Step 7: Verify.** Gate 1. Expected delta: +3 tests. + +- [ ] **Step 8: Commit** `feat(unit): one-sided bounds and limits checked at run time`. + +--- + +### Task 4: Declared decimals per quantity, and `same_unit` (#26) + +Spec §2.4. + +**Files:** +- Modify: `include/formula-cpp/quantity.hpp` (`Quantity` :96, `quantity_base_of` :123) +- Modify: `include/formula-cpp/unit.hpp` (a `with_decimals` helper and `same_unit`) +- Modify: `include/formula-cpp/trace.hpp` (`same_scale_and_symbol` :2193) +- Modify: tests: `test/quantity_tests.cpp` (or the file holding `Quantity` tests), `test/measured_tests.cpp`, + `test/number_text_tests.cpp`, the `std::format` test file, and one trace test file +- Modify: `docs/quantities.md`, `docs/dimensions.md`, `CHANGELOG.md` + +**Interfaces:** +- Consumes: `Unit::asciiText`, `view_ascii` (Task 2). +- Produces: + - `template struct Quantity;` + - `[[nodiscard]] constexpr bool same_unit(Unit const& leftUnit, Unit const& rightUnit) noexcept;` + - `[[nodiscard]] constexpr bool detail::same_scale(Unit const& leftUnit, Unit const& rightUnit) noexcept;` + - `[[nodiscard]] constexpr Unit detail::with_decimals(Unit declared, DecimalPlaces places) noexcept;` + +- [ ] **Step 1: Write the failing tests.** In the `Quantity` test file: + +```cpp +inline constexpr formula::Unit Milliampere { .dimension = formula::dim::Current, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, + .symbolText = formula::symbol("mA"), + .decimals = 0 }; +struct FineCurrent: formula::Quantity +{ +}; +struct CoarseCurrent: formula::Quantity +{ +}; + +TEST_CASE("Quantity: its own decimal places, in the unit it shares", "[quantity][decimals]") +{ + STATIC_REQUIRE(formula::Describe::unit.decimals == 1); + STATIC_REQUIRE(formula::Describe::unit.decimals == 0); + STATIC_REQUIRE(formula::same_unit(formula::Describe::unit, Milliampere)); + STATIC_REQUIRE(!(formula::Describe::unit == Milliampere)); + STATIC_REQUIRE(formula::Describe::unit == Milliampere); +} + +TEST_CASE("same_unit: what a unit is, not its decimals or bounds", "[unit][same_unit]") +{ + constexpr formula::Unit Base { .dimension = formula::dim::Length, .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, .symbolText = formula::symbol("mm") }; + constexpr formula::Unit OtherDecimals { .dimension = formula::dim::Length, .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, .symbolText = formula::symbol("mm"), + .decimals = 1, .bounds = formula::at_least(0, 1) }; + STATIC_REQUIRE(formula::same_unit(Base, OtherDecimals)); + STATIC_REQUIRE(!formula::same_unit(Base, formula::unit::Metre)); // magnitude + STATIC_REQUIRE(!formula::same_unit(formula::unit::Kelvin, formula::unit::Celsius)); // offset + constexpr formula::Unit OtherSymbol { .dimension = formula::dim::Length, .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, .symbolText = formula::symbol("MM") }; + STATIC_REQUIRE(!formula::same_unit(Base, OtherSymbol)); + constexpr formula::Unit OtherKey { .dimension = formula::dim::Length, .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, .symbolText = formula::symbol("mm"), + .asciiText = formula::symbol("millimetre") }; + STATIC_REQUIRE(!formula::same_unit(Base, OtherKey)); + constexpr formula::Unit SameKeySpelledOut { .dimension = formula::dim::Length, .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, .symbolText = formula::symbol("mm"), + .asciiText = formula::symbol("mm") }; + STATIC_REQUIRE(formula::same_unit(Base, SameKeySpelledOut)); // the same key, declared or read off the symbol + constexpr formula::Unit Area { .dimension = formula::dim::Length * formula::dim::Length, + .magnitudeNumerator = 1, .magnitudeDenominator = 1000, + .symbolText = formula::symbol("mm") }; + STATIC_REQUIRE(!formula::same_unit(Base, Area)); // dimension +} +``` + + Spell `dim::Current`, `Kelvin` and the dimension product as the headers do. `same_unit` compares the keys with + `view_ascii` (a key declared equal to the symbol is the same key as none declared): rule this in the report. + + In `test/measured_tests.cpp`: `checked_round_to_declared(Measured{ Rational { 1234, 100 } }, RoundingMode::HalfAwayFromZero)` + is `123/10`, and the same for `CoarseCurrent` is `12`. In `test/number_text_tests.cpp` and the `std::format` tests: + `Measured{ 12.34_r }` writes `12.3 mA`, `CoarseCurrent` writes `12 mA` (use each file's existing call + shape). In one trace test file (the one testing `Measured` inputs to a formula): a formula `FineCurrent * 2` + traced with input `12.34` shows its input at one place, matching how the file pins a 1-place unit's trace. + +- [ ] **Step 2: Run them and watch them fail** (quick loop, `-Filter "decimals|same_unit"`). Expected: "too many + template arguments for class template 'Quantity'". + +- [ ] **Step 3: Implement.** + - `unit.hpp`, in `namespace detail`: + +```cpp + /// @p declared with its display precision replaced by @p places: how a quantity that declares its own places + /// holds its unit. + [[nodiscard]] constexpr Unit with_decimals(Unit declared, DecimalPlaces places) noexcept + { + declared.decimals = places.value; + return declared; + } + + /// Whether two units put values on one scale: the same dimension, factor and offset, compared as declared. + [[nodiscard]] constexpr bool same_scale(Unit const& leftUnit, Unit const& rightUnit) noexcept + { + return leftUnit.dimension == rightUnit.dimension + && leftUnit.magnitudeNumerator == rightUnit.magnitudeNumerator + && leftUnit.magnitudeDenominator == rightUnit.magnitudeDenominator + && leftUnit.offsetNumerator == rightUnit.offsetNumerator + && leftUnit.offsetDenominator == rightUnit.offsetDenominator; + } +``` + + and public, after `has_ascii_key`: + +```cpp +/// Whether two units are the same unit: the same scale (dimension, factor, offset), the same display symbol and the +/// same key (`view_ascii`). Their declared decimals and bounds may differ -- a quantity read to a tenth of a +/// milliampere and one read to whole milliamperes are both in milliamperes -- which is what `==`, comparing every +/// member, does not answer. Use this, or `view_ascii`, to key a table by unit. +[[nodiscard]] constexpr bool same_unit(Unit const& leftUnit, Unit const& rightUnit) noexcept +{ + return detail::same_scale(leftUnit, rightUnit) && view(leftUnit.symbolText) == view(rightUnit.symbolText) + && view_ascii(leftUnit) == view_ascii(rightUnit); +} +``` + + - `trace.hpp` `same_scale_and_symbol` becomes `return same_scale(leftUnit, rightUnit) && view(leftUnit.symbolText) == view(rightUnit.symbolText);` + and its comment adds that it ignores the ASCII key, which is never displayed. + - `quantity.hpp`: `Quantity` gains `DecimalPlaces Places = declared_decimals(U)` and + `static constexpr Unit unit = detail::with_decimals(U, Places);` with a comment: `U`, with its declared places + replaced when the quantity declares its own; `quantity_base_of` gains the parameter in both its parameter and + return types. Document the parameter in `Quantity`'s comment, with the milliampere example. + +- [ ] **Step 4: Docs and CHANGELOG.** `docs/quantities.md`: the fifth parameter, with the milliampere example and the + note that `same_unit` answers whether two quantities share a unit. `docs/dimensions.md`: `same_unit` beside `==`. + CHANGELOG `### Added`: both. + +- [ ] **Step 5: Verify.** Gate 1. Expected delta: +2 tests, plus each test case the step-1 additions to the measured, + number text, format and trace files created (state how many). + +- [ ] **Step 6: Commit** `feat(quantity): declared decimals per quantity, and same_unit`. + +--- + +## Lane B — numbers (#24, #23) + +Lane B tasks run in agent-owned worktrees on `feature/unit-text-bounds-parsing-numbers`. Each begins with +`git reset --hard feature/unit-text-bounds-parsing-numbers`. `$T` is the worktree. + +--- + +### Task 5: Decimal text parsed at run time (#24) + +Spec §3.1. + +**Files:** +- Modify: `include/formula-cpp/detail/checked_int.hpp` (`pow10` :233, the 128-bit `mul_pow10` :386) +- Modify: `include/formula-cpp/rational.hpp` (`from_decimal` :128, `rational_from_spelling` :556, `operator""_r` :633) +- Create: `test/decimal_parsing_tests.cpp`; register it in `test/CMakeLists.txt` beside `rational_literal_tests.cpp` +- Modify: `test/rational_literal_tests.cpp`, `test/rational_tests.cpp` +- Modify: `test/negative/rational_literal_out_of_range.cpp`, `rational_literal_too_many_places.cpp`, + `rational_literal_exponent_out_of_range.cpp` +- Modify: `docs/numbers.md` (:24–31, :94–128), `CHANGELOG.md` + +**Interfaces:** +- Produces: + - `struct ParsedDecimal { Rational value; std::int32_t places; constexpr bool operator==(ParsedDecimal const&) const noexcept = default; };` + - `[[nodiscard]] constexpr std::expected parse_decimal_text(std::string_view spelling) noexcept;` + - `[[nodiscard]] static constexpr std::expected Rational::from_decimal_text(std::string_view spelling) noexcept;` + - `[[nodiscard]] constexpr std::optional detail::pow10_wide(int exponent) noexcept;` (0 to 38) + +- [ ] **Step 1: Write the failing tests** in `test/decimal_parsing_tests.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include +#include +#include +#include + +using formula::ArithmeticError; +using formula::ParsedDecimal; +using formula::Rational; + +namespace +{ + constexpr bool parses_as(std::string_view spelling, Rational expectedValue, std::int32_t expectedPlaces) + { + std::expected const parsed = formula::parse_decimal_text(spelling); + return parsed.has_value() && parsed->value == expectedValue && parsed->places == expectedPlaces; + } + + constexpr bool refused_as(std::string_view spelling, ArithmeticError expectedError) + { + std::expected const parsed = formula::parse_decimal_text(spelling); + return !parsed.has_value() && parsed.error() == expectedError; + } +} // namespace + +TEST_CASE("parse_decimal_text: the value and the places as typed", "[rational][text]") +{ + STATIC_REQUIRE(parses_as("2.400", Rational { 12, 5 }, 3)); + STATIC_REQUIRE(parses_as("2.4", Rational { 12, 5 }, 1)); + STATIC_REQUIRE(parses_as("7", Rational { 7 }, 0)); + STATIC_REQUIRE(parses_as("1.5e3", Rational { 1500 }, 0)); + STATIC_REQUIRE(parses_as("15e-1", Rational { 3, 2 }, 1)); + STATIC_REQUIRE(parses_as("1e-3", Rational { 1, 1000 }, 3)); + STATIC_REQUIRE(parses_as("2.5E+2", Rational { 250 }, 0)); + STATIC_REQUIRE(parses_as("-27.3", Rational { -273, 10 }, 1)); + STATIC_REQUIRE(parses_as("+0.0", Rational {}, 1)); + STATIC_REQUIRE(parses_as("-0", Rational {}, 0)); + STATIC_REQUIRE(parses_as("-0.0", Rational {}, 1)); + STATIC_REQUIRE(parses_as(".5", Rational { 1, 2 }, 1)); + STATIC_REQUIRE(parses_as("5.", Rational { 5 }, 0)); + STATIC_REQUIRE(parses_as("007", Rational { 7 }, 0)); + STATIC_REQUIRE(parses_as("0e99999", Rational {}, 0)); +} + +TEST_CASE("parse_decimal_text: text that is not a decimal is DomainError", "[rational][text]") +{ + for (std::string_view const spelling: { "", " 1", "1 ", "1,5", "1'000", "1_000", "1.2.3", "0x1F", "inf", "nan", + "1e", "1e+", "e5", "+", "-", ".", "+-1", "1e2.5", "--1" }) + { + CAPTURE(spelling); + REQUIRE(refused_as(spelling, ArithmeticError::DomainError)); + } +} + +TEST_CASE("parse_decimal_text: a value no Rational holds is Overflow", "[rational][text]") +{ + STATIC_REQUIRE(parses_as("170141183460469231731687303715884105727", + Rational { std::numeric_limits::max() }, 0)); + STATIC_REQUIRE(refused_as("170141183460469231731687303715884105728", ArithmeticError::Overflow)); + STATIC_REQUIRE(parses_as("1e38", *Rational::from_decimal(1, 38), 0)); + STATIC_REQUIRE(refused_as("1e39", ArithmeticError::Overflow)); + STATIC_REQUIRE(refused_as("1e-39", ArithmeticError::Overflow)); + STATIC_REQUIRE(refused_as("1e1001", ArithmeticError::Overflow)); + // A thousand fractional zeros before a digit: refused, not a wrong value and not a hang. + std::string const deepFraction = "0." + std::string(1000, '0') + "1"; + REQUIRE(refused_as(deepFraction, ArithmeticError::Overflow)); + // Trailing fractional zeros cost nothing, however many. + std::string const manyTrailingZeros = "1." + std::string(1000, '0'); + std::expected const trailing = formula::parse_decimal_text(manyTrailingZeros); + REQUIRE(trailing.has_value()); + REQUIRE(trailing->value == Rational { 1 }); + REQUIRE(trailing->places == 1000); +} + +TEST_CASE("Rational::from_decimal_text: the value parse_decimal_text reads", "[rational][text]") +{ + STATIC_REQUIRE(*Rational::from_decimal_text("2.400") == Rational { 12, 5 }); + STATIC_REQUIRE(Rational::from_decimal_text("2,4").error() == ArithmeticError::DomainError); + std::string const fromCsv = "0.0213"; + std::expected const atRunTime = Rational::from_decimal_text(fromCsv); + REQUIRE(atRunTime.has_value()); + REQUIRE(*atRunTime == Rational { 213, 10'000 }); +} +``` + + Check every expected value above against the spec's syntax before relying on it; `"1e2.5"` is refused because an + exponent is digits only. + + In `test/rational_tests.cpp`: + +```cpp +TEST_CASE("from_decimal: exponents up to 38 either way", "[rational]") +{ + STATIC_REQUIRE(*Rational::from_decimal(1, -19) == Rational::make(1, *formula::detail::pow10_wide(19)).value()); + STATIC_REQUIRE(Rational::from_decimal(1, -38).has_value()); + STATIC_REQUIRE(Rational::from_decimal(1, -39).error() == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(Rational::from_decimal(1, 38).has_value()); + STATIC_REQUIRE(Rational::from_decimal(1, 39).error() == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(*Rational::from_decimal(10, -39) == *Rational::from_decimal(1, -38)); // trailing zeros fold first +} +``` + + In `test/rational_literal_tests.cpp`: + +```cpp +TEST_CASE("_r: a literal wider than 64 bits", "[rational][literal]") +{ + STATIC_REQUIRE(12'345'678'901'234'567'890_r == 1'234'567'890_r * 10'000'000'000_r + 1'234'567'890_r); + STATIC_REQUIRE(0.000'000'000'000'000'000'1_r == *Rational::from_decimal(1, -19)); +} +``` + +- [ ] **Step 2: Run them and watch them fail** (quick loop, `-Filter "text|from_decimal|literal"`). + +- [ ] **Step 3: `checked_int.hpp`.** Add, in the 128-bit section, before the 128-bit `mul_pow10`: + +```cpp +/// `10^exponent` on 128 bits, for `0 <= exponent <= 38`; `nullopt` otherwise. 10^38 is the largest power of ten +/// `Int128` holds. +[[nodiscard]] constexpr std::optional pow10_wide(int exponent) noexcept +{ + if (exponent < 0 || exponent > 38) + return std::nullopt; + Int128 power { 1 }; + for (int multiplied = 0; multiplied < exponent; ++multiplied) + power = power * Int128 { 10 }; + return power; +} +``` + + and the 128-bit `mul_pow10` uses `pow10_wide` (exponents 0 to 38); update its comment. It has one caller, + `from_decimal`. + +- [ ] **Step 4: `from_decimal`.** Fold the mantissa's trailing zeros into a negative exponent first + (`while (exponent < 0 && mantissa % 10 == 0) { mantissa /= 10; ++exponent; }`, after the zero check), then the + negative branch uses `detail::pow10_wide(-exponent)`. Its comment states the range: 10^-38 to 10^38. + +- [ ] **Step 5: The shared parser.** In `rational.hpp`, after the class and before `namespace detail` holding the + literal sentinels: `ParsedDecimal` (public, `///` on each member: `value` is the exact value, `places` the digits + after the point minus the exponent, never below 0), then in `namespace detail`: + +```cpp + /// Which spelling `parse_decimal` reads: run-time text, or a `_r` literal's spelling. + enum class DecimalSyntax : std::uint8_t + { + /// An optional sign, digits with at most one point, an optional exponent; leading zeros are fine. + Text, + /// What C++ hands a literal operator: no sign, digit separators (`'`), and no leading zero that C++ reads + /// as octal, hexadecimal or binary. + Literal, + }; + + /// The exact value of a decimal spelling, and its places as typed. `DomainError` for a spelling that is not a + /// decimal in @p syntax, `Overflow` for a value no `Rational` holds. The mantissa accumulates in 128 bits; + /// fractional zeros are deferred, so trailing ones cost nothing. + [[nodiscard]] constexpr std::expected parse_decimal(std::string_view spelling, + DecimalSyntax syntax) noexcept + { + bool const literal = syntax == DecimalSyntax::Literal; + std::size_t at = 0; + bool negative = false; + if (!literal && at < spelling.size() && (spelling[at] == '+' || spelling[at] == '-')) + { + negative = spelling[at] == '-'; + ++at; + } + if (literal && spelling.size() > 1 && spelling[0] == '0' + && spelling.find_first_of(".eE") == std::string_view::npos) + return std::unexpected { ArithmeticError::DomainError }; // 017, 0x1F, 0b101 + Rational::Int mantissa { 0 }; + std::int64_t decimalScale = 0; // decimal exponent the digits carry + std::int64_t pendingZeros = 0; // fractional zeros not yet multiplied in + std::int64_t fractionDigits = 0; // digits after the point, as typed + bool inFraction = false; + bool sawDigit = false; + for (; at < spelling.size() && spelling[at] != 'e' && spelling[at] != 'E'; ++at) + { + char const symbolAt = spelling[at]; + if (literal && symbolAt == '\'') + continue; + if (symbolAt == '.') + { + if (inFraction) + return std::unexpected { ArithmeticError::DomainError }; + inFraction = true; + continue; + } + if (symbolAt < '0' || symbolAt > '9') + return std::unexpected { ArithmeticError::DomainError }; + sawDigit = true; + int const digitValue = symbolAt - '0'; + if (inFraction) + ++fractionDigits; + if (inFraction && digitValue == 0) + { + ++pendingZeros; + continue; + } + for (std::int64_t zero = 0; zero <= pendingZeros; ++zero) // the zeros, then this digit's place + { + std::optional const shifted = mul_checked_or_none(mantissa, Rational::Int { 10 }); + if (!shifted) + return std::unexpected { ArithmeticError::Overflow }; + std::optional const placed = + add_checked_or_none(*shifted, Rational::Int { zero == pendingZeros ? digitValue : 0 }); + if (!placed) + return std::unexpected { ArithmeticError::Overflow }; + mantissa = *placed; + } + if (inFraction) + decimalScale -= pendingZeros + 1; + pendingZeros = 0; + } + if (!sawDigit) + return std::unexpected { ArithmeticError::DomainError }; + std::int64_t exponentValue = 0; + if (at < spelling.size()) // at an e or E + { + ++at; + bool exponentNegative = false; + if (at < spelling.size() && (spelling[at] == '+' || spelling[at] == '-')) + { + exponentNegative = spelling[at] == '-'; + ++at; + } + bool sawExponentDigit = false; + for (; at < spelling.size(); ++at) + { + if (literal && spelling[at] == '\'') + continue; + if (spelling[at] < '0' || spelling[at] > '9') + return std::unexpected { ArithmeticError::DomainError }; + sawExponentDigit = true; + if (exponentValue <= 1'000) // bounded while read: anything above is refused below + exponentValue = exponentValue * 10 + (spelling[at] - '0'); + } + if (!sawExponentDigit) + return std::unexpected { ArithmeticError::DomainError }; + if (exponentNegative) + exponentValue = -exponentValue; + } + std::int64_t const placesTyped = fractionDigits - exponentValue; + if (placesTyped > std::numeric_limits::max()) + return std::unexpected { ArithmeticError::Overflow }; + std::int32_t const places = placesTyped < 0 ? 0 : static_cast(placesTyped); + if (mantissa == 0) + return ParsedDecimal { Rational {}, places }; + if (exponentValue > 1'000 || exponentValue < -1'000) // as `_r` has always refused + return std::unexpected { ArithmeticError::Overflow }; + std::int64_t const totalScale = decimalScale + exponentValue; + if (totalScale > 1'000 || totalScale < -1'000) // keeps the narrowing to int safe; from_decimal decides the rest + return std::unexpected { ArithmeticError::Overflow }; + std::expected const made = + Rational::from_decimal(mantissa, static_cast(totalScale)); + if (!made) + return std::unexpected { made.error() }; + return ParsedDecimal { negative ? -*made : *made, places }; + } +``` + + The mantissa is never negative here, so `-*made` cannot overflow. A zero value answers before the exponent is + judged, so `0e99999` is 0; in literal mode that turns the old refusal of `0e1001_r` into 0, which the behaviour rule + allows. Construct a `Rational` from an `Int128` with `Rational(Int)` (:72). + + Then the public functions, after `namespace detail` closes: + +```cpp +/// Parses decimal text that arrives at run time -- a CSV import, a form field, a configuration value -- into its +/// exact value and the places it was typed to: `"2.400"` is 12/5 at 3 places, `"2.4"` 12/5 at 1. An optional sign, +/// digits with at most one point, an optional exponent (`e` or `E`, an optional sign, digits). `DomainError` for +/// anything else -- whitespace, a decimal comma, separators, `inf`, `nan` -- and `Overflow` for a value no `Rational` +/// holds. The same parser reads `_r` literals. +[[nodiscard]] constexpr std::expected parse_decimal_text(std::string_view spelling) noexcept +{ + return detail::parse_decimal(spelling, detail::DecimalSyntax::Text); +} +``` + + `Rational::from_decimal_text` is declared in the class with a `///` comment ("the value `parse_decimal_text` reads") + and defined after `parse_decimal_text`: + +```cpp +constexpr std::expected Rational::from_decimal_text(std::string_view spelling) noexcept +{ + std::expected const parsed = parse_decimal_text(spelling); + if (!parsed) + return std::unexpected { parsed.error() }; + return parsed->value; +} +``` + +- [ ] **Step 6: `_r` on the shared parser.** `rational_from_spelling` becomes: + +```cpp + consteval Rational rational_from_spelling(char const* spelling) + { + std::expected const parsed = + parse_decimal(std::string_view { spelling }, DecimalSyntax::Literal); + if (!parsed && parsed.error() == ArithmeticError::DomainError) + formula_rational_literal_not_a_decimal(); + if (!parsed) + formula_rational_literal_out_of_range(); + return parsed->value; + } +``` + + Update `operator""_r`'s comment: a spelling no `Rational` holds (more than 128 bits, or an exponent beyond 10^±38 + once trailing zeros fold) fails to compile. + +- [ ] **Step 7: Move the negative tests that now compile.** + - `rational_literal_out_of_range.cpp`: `170'141'183'460'469'231'731'687'303'715'884'105'728_r` (2^127, one past + `Int128`'s maximum); comment says so. + - `rational_literal_too_many_places.cpp`: `0.000'000'000'000'000'000'000'000'000'000'000'000'001_r` (10^-39); + comment: a denominator above `Int128`'s range. + - `rational_literal_exponent_out_of_range.cpp`: `1e39_r`; comment: 10^39 is an integer no `Rational` holds + (`Int128` stops below 1.8 × 10^38). + - Each existing `_r` test and every other `rational_literal_*` negative must pass unchanged. + +- [ ] **Step 8: Docs and CHANGELOG.** `docs/numbers.md`: the literal limits become 128 bits and 10^±38 (the table + at :119–122 and its prose); a new section *Decimal text at run time* with `parse_decimal_text`, `from_decimal_text`, + the syntax, the errors, and places as typed, using `std::println` in any sample. CHANGELOG `### Added`: the parser; + `### Changed`: `_r` and `from_decimal` take 128-bit values and exponents to ±38 (not breaking: only refusals turn + into answers). + +- [ ] **Step 9: Verify.** Gate 1; gate 2 with `-Filter "rational_literal"`. Expected delta: +6 tests, +0 negatives. + +- [ ] **Step 10: Commit** `feat(rational): parse decimal text at run time, on the parser _r uses`. + +--- + +### Task 6: `checked_transform` and `checked_combine` (#23) + +Spec §3.2. + +**Files:** +- Modify: `include/formula-cpp/measured.hpp` (after `combine` :165) +- Modify: `test/measured_tests.cpp` +- Create: `test/negative/checked_transform_plain_callback.cpp`, `test/negative/checked_combine_plain_callback.cpp` +- Modify: `test/CMakeLists.txt`, `docs/quantities.md` (:251–273), `CHANGELOG.md` + +**Interfaces:** +- Produces: + - `template constexpr std::expected, ArithmeticError> checked_transform(Measured measured, F function) noexcept(std::is_nothrow_invocable_v);` + - `template constexpr std::expected, ArithmeticError> checked_combine(Measured lhs, Measured rhs, F function) noexcept(std::is_nothrow_invocable_v);` + + (`lhs` and `rhs` are the names `combine` already uses; keep them for symmetry.) + +- [ ] **Step 1: Write the failing tests** in `test/measured_tests.cpp`, with the file's existing quantities (`Q`, `R`, + and a third for the combined result): + +```cpp +TEST_CASE("checked_transform: present, absent, and an error from the callback", "[measured][checked]") +{ + using formula::ArithmeticError; + using formula::Rational; + constexpr auto timesTen = [](Rational reading) noexcept { return formula::checked_mul(reading, Rational { 10 }); }; + constexpr std::expected, ArithmeticError> diluted = + formula::checked_transform(formula::Measured { Rational { 3, 2 } }, timesTen); + STATIC_REQUIRE(diluted.has_value()); + STATIC_REQUIRE(diluted->value() == Rational { 15 }); + + int calls = 0; + auto const counting = [&calls](Rational reading) { + ++calls; + return std::expected { reading }; + }; + std::expected, ArithmeticError> const absent = + formula::checked_transform(formula::Measured {}, counting); + REQUIRE(absent.has_value()); + REQUIRE(absent->is_absent()); + REQUIRE(calls == 0); + + constexpr auto overflowing = [](Rational) noexcept { + return std::expected { std::unexpected { ArithmeticError::Overflow } }; + }; + constexpr std::expected, ArithmeticError> refused = + formula::checked_transform(formula::Measured { Rational { 1 } }, overflowing); + STATIC_REQUIRE(refused.error() == ArithmeticError::Overflow); + + STATIC_REQUIRE(noexcept(formula::checked_transform(formula::Measured {}, timesTen))); + STATIC_REQUIRE(!noexcept(formula::checked_transform(formula::Measured {}, counting))); +} + +TEST_CASE("checked_combine: absent if either is absent; an error propagates", "[measured][checked]") +{ + using formula::ArithmeticError; + using formula::Rational; + constexpr auto multiply = [](Rational lhsReading, Rational rhsReading) noexcept { + return formula::checked_mul(lhsReading, rhsReading); + }; + constexpr std::expected, ArithmeticError> product = + formula::checked_combine(formula::Measured { Rational { 2 } }, formula::Measured { Rational { 3 } }, multiply); + STATIC_REQUIRE(product.has_value()); + STATIC_REQUIRE(product->value() == Rational { 6 }); + constexpr std::expected, ArithmeticError> oneAbsent = + formula::checked_combine(formula::Measured {}, formula::Measured { Rational { 3 } }, multiply); + STATIC_REQUIRE(oneAbsent.has_value()); + STATIC_REQUIRE(oneAbsent->is_absent()); + constexpr auto dividing = [](Rational lhsReading, Rational rhsReading) noexcept { + return formula::checked_div(lhsReading, rhsReading); + }; + constexpr std::expected, ArithmeticError> byZero = + formula::checked_combine(formula::Measured { Rational { 2 } }, formula::Measured { Rational {} }, dividing); + STATIC_REQUIRE(byZero.error() == ArithmeticError::DivisionByZero); + STATIC_REQUIRE(noexcept(formula::checked_combine(formula::Measured {}, formula::Measured {}, multiply))); +} +``` + + Use the real name of the checked division in `rational.hpp` (it sits after `checked_mul`, :398–407). + +- [ ] **Step 2: Run them and watch them fail** (quick loop, `-Filter "checked_transform|checked_combine"`). + +- [ ] **Step 3: Implement** in `measured.hpp`, after `combine` (add ``): + +```cpp +namespace detail +{ + /// Whether @p F, called with @p Arguments, returns exactly `std::expected`. + template + inline constexpr bool returns_checked_rational = + std::is_same_v, std::expected>; +} // namespace detail + +/// `transform` for a callback that can fail: @p function returns `std::expected` -- +/// `checked_mul` and its siblings, say -- and its error is returned unchanged. An absent measurement stays absent +/// without calling @p function. `noexcept` when @p function is, so one line of arithmetic on a measurement can be +/// written under a no-throw rule. +template +[[nodiscard]] constexpr std::expected, ArithmeticError> checked_transform(Measured measured, F function) + noexcept(std::is_nothrow_invocable_v) +{ + static_assert(detail::returns_checked_rational, + "formula: a checked_transform callback must return std::expected; use " + "transform for a callback that returns a Rational"); + if (measured.is_absent()) + return Measured {}; + std::expected const transformed = function(measured.value()); + if (!transformed) + return std::unexpected { transformed.error() }; + return Measured { *transformed }; +} + +/// `combine` for a callback that can fail, as `checked_transform` is for `transform`. Absent if either measurement +/// is absent, without calling @p function. +template +[[nodiscard]] constexpr std::expected, ArithmeticError> checked_combine(Measured lhs, + Measured rhs, + F function) + noexcept(std::is_nothrow_invocable_v) +{ + static_assert(detail::returns_checked_rational, + "formula: a checked_combine callback must return std::expected; use " + "combine for a callback that returns a Rational"); + if (lhs.is_absent() || rhs.is_absent()) + return Measured {}; + std::expected const combined = function(lhs.value(), rhs.value()); + if (!combined) + return std::unexpected { combined.error() }; + return Measured { *combined }; +} +``` + + `measured.value()` throws only for an absent value, which is returned before it; if a compiler warns that a + `noexcept` function calls a throwing one, read the value through the member that does not throw instead (look at + `Measured`'s accessors, :98–108) rather than suppressing the warning. + +- [ ] **Step 4: Negative tests.** `checked_transform_plain_callback.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// checked_transform's callback reports failure through std::expected; one returning a bare Rational belongs to +// transform. This must not compile. +#include +// … the smallest quantity declaration the other measured negatives use … + +int main() +{ + auto const plain = [](formula::Rational reading) { return reading; }; + auto const refused = formula::checked_transform(formula::Measured { formula::Rational { 1 } }, plain); + return refused.has_value() ? 0 : 1; +} +``` + + and `checked_combine_plain_callback.cpp` likewise for `checked_combine`. Register them with + `"formula: a checked_transform callback must return std::expected"` and + `"formula: a checked_combine callback must return std::expected"`. + +- [ ] **Step 5: Docs and CHANGELOG.** `docs/quantities.md` after `transform`/`combine`: the checked forms, with the + dilution example (`checked_transform(reading, [](Rational r) noexcept { return checked_mul(r, Rational { 10 }); })` + — rename `r` in prose samples if they are compiled by a docs test that includes consumer globals). CHANGELOG + `### Added`. + +- [ ] **Step 6: Verify.** Gate 1; gate 2 with `-Filter "checked_(transform|combine)"`. Expected delta: +2 tests, + +2 negatives. + +- [ ] **Step 7: Commit** `feat(measured): checked_transform and checked_combine`. + +--- + +## Final tasks + +### Task 7: Merge Lane B + +Run by the controller in `D:\formula-cpp`. + +- [ ] **Step 1:** `git merge --no-ff feature/unit-text-bounds-parsing-numbers -m "Merge decimal text parsing and checked measured arithmetic"` + (message file with the sign-off). +- [ ] **Step 2:** Resolve `CHANGELOG.md` (keep every entry; within each subsection, breaking entries first) and + `measured.hpp` (both additions kept, each where its task put it). An `sdd-implementer` resolves anything else. +- [ ] **Step 3: Verify.** Gate 1; gate 2 with `-Filter "."` (every negative on both compilers). The total must equal + the Lane A total plus the Lane B deltas. + +### Task 8: The guides, read through once + +- [ ] **Step 1:** Read `docs/dimensions.md`, `docs/quantities.md`, `docs/numbers.md` and the README's units part + end to end. Fix anything that contradicts what the branch now does (the old capacity, `Bounds::present`, the + 64-bit literal limits, a sentence saying units are keyed outside the library), and make the new sections agree in + terms with each other (`ASCII key`, `checked_*`). +- [ ] **Step 2:** `git grep -n "SymbolCapacity = 16\|15 usable\|bounds.present\|64-bit mantissa"` finds nothing + outside `docs/superpowers/` and released CHANGELOG sections. +- [ ] **Step 3: Verify.** Gate 1 (the docs output tests run there). Commit `docs: …` if anything changed. + +### Task 9: Finish + +The controller runs this task. Fix rounds go to `sdd-implementer`; the whole-branch review and each re-review go to +`sdd-reviewer`. + +- [ ] **Step 1: Whole-branch review** of `931e81c..HEAD` against the spec; fix every finding, re-review. +- [ ] **Step 2: All eight presets.** `pwsh -NoProfile -File $S\windows-matrix.ps1 -Tree D:\formula-cpp` and + `wsl bash /mnt/c/Users/c.parpart/AppData/Local/Temp/claude/D--formula-cpp/81d1061b-b25f-4c83-9f67-664a67264017/scratchpad/posix-matrix.sh --tree /mnt/d/formula-cpp` + (from PowerShell). Every preset green. +- [ ] **Step 3: Docs builds.** Doxygen with warnings as errors, and `mkdocs build --strict` + (`$S\docs-pages.sh`). +- [ ] **Step 4: Pull request.** Push the branch; open a draft PR titled + `Unit keys and capacity, one-sided bounds, per-quantity decimals, decimal text parsing, checked measured arithmetic` + whose body describes the changes in user terms, lists the breaking ones, and ends with `Closes #22` … `Closes #27` + (one per line). +- [ ] **Step 5: CI.** Wait for every job; fix any failure on the branch. When green, `gh pr ready`. The owner merges. diff --git a/docs/superpowers/specs/2026-10-05-unit-text-bounds-parsing-design.md b/docs/superpowers/specs/2026-10-05-unit-text-bounds-parsing-design.md new file mode 100644 index 0000000..0d824f2 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-unit-text-bounds-parsing-design.md @@ -0,0 +1,320 @@ +# Unit text, bounds, per-quantity decimals and run-time parsing — design + +**Status:** draft, ready for review · **Date:** 2026-10-05 · **Owner:** Christian Parpart + +Resolves issues #22, #23, #24, #25, #26 and #27: every issue open on 2026-10-05. All six came from porting a +laboratory application (concentrations in mg/L, µg/L and ng/L, dilution factors, currents in A and mA) onto 0.3.0. +They land together, on the branch `feature/unit-text-bounds-parsing` from `master` at `6ef3b19`, in one pull request +whose body closes all six. + +0.4.0 is released, so these changes would ship in 0.5.0. Several break the public API, which a minor release before +1.0 may do; each such change is listed in §6. + +## 1. Delivery + +Parameter names in this document's signatures are descriptive; the plan gives the names the code uses, since a +test translation unit declares globals such as `value`, `text`, `low` and `high` that a parameter must not shadow. + +| Group | Issues | Main files | +|---|---|---| +| Lane A: units | #27, #22, #25, #26 | `dimension.hpp`, `unit.hpp`, `quantity.hpp`, the unit checks, `trace.hpp` (`unit_quotient`), `measured.hpp` (bounds overloads) | +| Lane B: numbers | #24, then #23 | `rational.hpp`, `detail/checked_int.hpp`, `measured.hpp` (`checked_transform`, `checked_combine`) | +| Final tasks | — | merge Lane B; `docs/dimensions.md`, `docs/quantities.md`, `docs/numbers.md`, `README.md`; finish | + +- Lane A and Lane B run in parallel, each in its own worktree. Lane B merges into the branch before the final tasks. + Both lanes add to `measured.hpp` and to `CHANGELOG.md`; the merge task resolves those, keeping breaking entries + first. +- Lane A runs in the order above: #22 needs #27's `SymbolError` and wider `Symbol`, and #26's `same_unit` compares + #22's ASCII key. +- Each task updates the guide text it changes and adds its CHANGELOG entry under `## [Unreleased]`. A breaking + entry says so in its first words. The final docs task covers what no single task owns: the README note, and a + read-through of the three guides for consistency. +- No version bump: the release is a separate step. + +## 2. Lane A: units + +### 2.1 #27: symbol capacity, and building a symbol at run time + +**Problem.** + +- `SymbolCapacity` is 16 bytes including the terminator (`dimension.hpp:189`). Compound laboratory units are longer + in UTF-8: `µmol/(L·min·kg)` is 18 bytes. +- `symbol(char const*)` (`dimension.hpp:230`) is `constexpr`. A symbol that does not fit is a compile error in a + constant expression, but at run time it reaches `detail::formula_unit_symbol_too_long()`, which calls + `std::abort()`. A unit built from run-time text (a catalogue, a configuration file) can terminate the process. + +**Design.** + +- `SymbolCapacity` becomes 32: 31 usable bytes. + - `NumberTextCapacity` (128) still holds `LongestNumberText`, which grows from 97 to 113; its `static_assert` + stays. + - `Unit` grows by 16 bytes per `Symbol` it holds, and `Dimension` by 64 (four named-base symbols). No test pins + these sizes. + - `unit_quotient` (`trace.hpp`) already checks the capacity, so derived quotient symbols simply get more room. +- `symbol(char const*)` becomes `consteval`. A literal that does not fit stays a compile error, with the existing + sentinel's name in the diagnostic. A call with a pointer that is not a constant expression no longer compiles, so + no run-time path reaches `std::abort()`. The sentinel itself is unchanged: it is not `constexpr`, so reaching it + during constant evaluation is the refusal, and a `consteval` caller can no longer reach it at run time. Its + comment says so. +- `base_dimension` is already `consteval` and keeps its own length check. +- New `enum class SymbolError : std::uint8_t { TooLong, EmbeddedNull, NotAscii }`, with + `describe(SymbolError) -> std::string_view`, in `dimension.hpp` beside `Symbol`. It is not an `ArithmeticError`: + a symbol that does not fit is not an arithmetic failure. +- New `constexpr std::expected checked_symbol(std::string_view text) noexcept`: + - `TooLong` when `text.size() + 1 > SymbolCapacity`; + - `EmbeddedNull` when `text` contains `'\0'`, which would cut the stored symbol short without notice; + - otherwise the symbol, byte for byte. UTF-8 is not validated: a symbol is bytes, as today. + +**Tests.** + +- `checked_symbol`: an 18-byte UTF-8 symbol fits; 31 bytes fit; 32 bytes give `TooLong`; an embedded NUL gives + `EmbeddedNull`; the empty text gives the empty symbol. Each also checked in a `static_assert`. +- `describe(SymbolError)` for every enumerator. +- `symbol("µmol/(L·min·kg)")` compiles. +- Negative tests: `unit_symbol_too_long` moves to a 32-byte literal; a new one passes `symbol()` a run-time pointer + and expects the `consteval` refusal. `base_dimension_name_too_long` moves to a 32-byte name. +- `test/number_text_tests.cpp`'s widest-symbol case is rebuilt for the new capacity, so it still tests the widest + symbol. +- `docs/dimensions.md` (the capacity, at its two places) and the CHANGELOG's old capacity note stay accurate: the + guide is updated, the released CHANGELOG entry is not. + +### 2.2 #22: a stable ASCII key per unit + +**Problem.** `Unit` has one text field, `symbolText`. Code that serialises units (JSON-schema annotations, payload +tags, a database column naming a unit, a client's unit choice) needs a key that stays the same when the display +text is restyled (`µ` or `u`, `·`, superscripts). `µg/L` has no ASCII spelling. + +**Decision (owner).** A unit whose symbol is not ASCII must declare an ASCII key; this is refused at compile time, +so the accessor returns a plain `std::string_view`. + +**Design.** + +- `Unit` gains `Symbol asciiText {}`, directly after `symbolText`. Every `Unit {…}` in the repository uses + designated initialisers, so no initialiser breaks. `Unit` stays structural. +- **ASCII** here means printable ASCII, bytes `0x20` to `0x7E`. The empty text is ASCII. +- New `constexpr std::string_view view_ascii(Unit const& unitValue) noexcept`: `asciiText` when it is not empty, + otherwise `symbolText`. `view_ascii(Unit&&)` is deleted, as `view(Symbol&&)` is. +- New `constexpr bool has_ascii_key(Unit const& unitValue) noexcept`: whether `view_ascii` is ASCII, and + `asciiText`, when set, is ASCII. +- New `constexpr std::expected checked_ascii_symbol(std::string_view text) noexcept`: + `checked_symbol`'s checks, plus `NotAscii` for any byte outside `0x20`-`0x7E`. A `Unit` built at run time takes its + key from it. +- New check `detail::RequireAsciiKey`, failing when `!has_ascii_key(U)`, with the message + `formula: a unit whose symbol is not ASCII must declare an ASCII key (asciiText)`. It is asserted beside + `detail::RequireNamedScaledScalar` at every place that asserts that check (24 today: a quantity's description, + constants, rounding, table keys and results, and the rest), gated the same way, so one mistake gives one message. +- The built-in units with non-ASCII symbols get keys: + + | Unit | Symbol | `asciiText` | + |---|---|---| + | `PerMille` | `‰` | `permille` | + | `Micrometre` | `µm` | `um` | + | `DegreeCelsius` | `°C` | `degC` | + | `DegreeFahrenheit` | `°F` | `degF` | + + The plan uses the names the header gives these units. +- `unit_quotient` (`trace.hpp`) builds the derived unit's key: when either operand has an `asciiText`, the quotient's + `asciiText` is `view_ascii(over)`, `/`, and `view_ascii(under)`, bracketed by the same rule as the symbol. If the + key does not fit, there is no quotient unit (`std::nullopt`), as when the symbol does not fit. +- The key is for serialising only. `render()`, traces, `number_text` and `std::format` keep writing `symbolText`. +- `README.md` gains a short note under units: serialise a unit with `view_ascii`, not its display symbol. + +**Tests.** + +- `view_ascii` of a plain ASCII unit returns its symbol; of each of the four built-ins, its key; of a coherent unit, + the empty text. +- `has_ascii_key` is false for a unit with a non-ASCII symbol and no key, and for a non-ASCII key. +- `checked_ascii_symbol`: `NotAscii` for `µg/L`; `TooLong` and `EmbeddedNull` as `checked_symbol`. +- A trace quotient of a µm-based unit by a time carries the key `um/s`. +- Negative tests: a quantity, a constant and a table key in a unit with symbol `µg/L` and no key, each expecting + `must declare an ASCII key`. +- Every unit in the repository's tests, examples and guides passes the check, or gets a key. + +### 2.3 #25: one-sided bounds, and bounds known at run time + +**Problem.** `Bounds` has one `bool present` for both ends (`unit.hpp:28`), and `checked_within_bounds` reads +bounds only from a `Unit`. Specification limits read at run time can have a lower end, an upper end, or both. Today +a one-sided limit needs a sentinel, and checking any run-time limit needs a copied `Unit`, which then no longer +compares equal to the original. + +**Design.** + +- `Bounds` replaces `bool present` with `bool lowPresent = false` and `bool highPresent = false`, ahead of the four + `int64` fields, which keep their names and defaults. `operator==` stays defaulted. +- Factories: + - `bounds(lowNumerator, lowDenominator, highNumerator, highDenominator)`: both ends, as today. + - New `at_least(numerator, denominator)`: the lower end only. + - New `at_most(numerator, denominator)`: the upper end only. +- New `constexpr std::expected checked_within(Rational value, + std::optional low, std::optional high) noexcept`: + - no end given: `NotChecked`; + - both ends given and `low > high`: `DomainError`; + - below `low`: `BelowMinimum`; above `high`: `AboveMaximum`; otherwise `WithinBounds`. Both ends are inclusive. +- `checked_within_bounds(Rational, Unit)` builds the ends its `Bounds` declares (with `Rational::make`, so a zero + denominator still gives `DivisionByZero`) and calls `checked_within`. There is one comparison rule. +- In `measured.hpp`: + - `checked_within(Measured, std::optional low, std::optional high)`: `NotMeasured` for an + absent value, otherwise as above. + - A throwing `within(...)` for each, built on `detail::or_throw`, as `within_bounds` is. +- Every reader of `Bounds::present` (in headers, tests, examples and guides) moves to the per-end flags. + +**Tests.** + +- `checked_within`: lower only, upper only, both, neither; a value equal to each end; `low > high`; a `Measured` + absent value. +- `checked_within_bounds` on a unit declared with `at_least` and with `at_most`; the existing two-sided cases + unchanged; a zero denominator still `DivisionByZero`. +- The positional `Bounds` initialisers in `test/unit_tests.cpp` move to designated or factory form. + +### 2.4 #26: declared decimals per quantity, and `same_unit` + +**Problem.** A `Quantity` takes its declared decimals only from its `Unit`. Two quantities in one unit cannot +declare different precision without a second `Unit`, which then compares unequal to the first, because +`Unit::operator==` compares `decimals` and `bounds` too. + +**Decision (owner).** `Unit::operator==` keeps comparing every member. A new `same_unit` compares what a unit is. + +**Design.** + +- `Quantity` gains a defaulted fifth parameter: + `template struct Quantity`. + - Its `unit` member is `U` with `decimals` set to `Places.value`. Every reader of a quantity's declared places + therefore honours it unchanged: `checked_round_to_declared`, `number_text`, `std::format`, traces and + `render()`. + - Its `Describe` specialisation exposes that `unit`, as today. A `Describe` the application writes is + unaffected. + - The unit checks (§2.2's, #14's and the rest) apply to `U`, as today. +- New `constexpr bool same_unit(Unit const& leftUnit, Unit const& rightUnit) noexcept`: equal dimension, magnitude, + offset, `symbolText`, and key (`view_ascii`, so a key declared equal to the symbol is the same key as none + declared); `decimals` and `bounds` are ignored. +- `detail::same_scale_and_symbol` (`trace.hpp`) compares what a unit shows, so it keeps ignoring `asciiText`. It + and `same_unit` share one helper for the dimension, magnitude and offset comparison; the plan names it. + +**Tests.** + +- A quantity in mA with `DecimalPlaces { 1 }`: `checked_round_to_declared`, `number_text` and `std::format` use one + place; `same_unit(Q::unit, mA)` is true; `Q::unit == mA` is false. A quantity without the parameter is unchanged. +- `same_unit` is false when dimension, magnitude, offset, symbol or key differ, and true when only decimals or + bounds differ. +- A trace of a formula over the 1-place quantity shows its values at one place. + +## 3. Lane B: numbers + +### 3.1 #24: decimal text parsed at run time + +**Problem.** The only text-to-`Rational` path is the `consteval` `operator""_r`. Values that arrive as text at run +time (CSV or instrument imports, form fields, configuration) have no library parser. Applications that enforce a +declared precision also need the places as typed: `"2.400"` is not `"2.4"`. + +**Design.** + +- New in `rational.hpp`: + - `struct ParsedDecimal { Rational value; std::int32_t places; }`, with defaulted `operator==`. + - `constexpr std::expected parse_decimal_text(std::string_view text) noexcept`. + - `static constexpr std::expected Rational::from_decimal_text(std::string_view text) + noexcept`: `parse_decimal_text`'s value. +- **Syntax:** + - an optional `+` or `-`; + - digits with at most one `.`, and at least one digit in all (`.5` and `5.` are accepted); + - an optional exponent: `e` or `E`, an optional `+` or `-`, and at least one digit; + - leading zeros are accepted (`007`). +- **Refused with `DomainError`:** the empty text; whitespace anywhere (the caller trims); a decimal comma; any + separator, including `'`; a second `.`; hexadecimal; `inf` and `nan`; an exponent with no digits; anything else. +- **Refused with `Overflow`:** a value no `Rational` holds. +- **Places as typed:** the digits after the `.`, minus the exponent, and never below 0. `"2.400"` gives 3, `"2.4"` + gives 1, `"1.5e3"` gives 0, `"15e-1"` gives 1, `"7"` gives 0. +- `"-0"` and `"+0.0"` give 0 (with places 0 and 1). +- **Range:** the mantissa accumulates in `Int128`. Trailing fractional zeros are deferred and cost nothing unless a + non-zero digit follows, as in `_r`. An exponent with very many digits is bounded while it is read, so reading it + cannot overflow: a non-zero value with such an exponent is `Overflow`, a zero value is 0. +- **One parser.** The parsing core becomes a `constexpr` detail function returning `std::expected`, with a literal + mode for `_r`. The literal mode accepts `'` separators, refuses a leading `0` not followed by `.`, `e` or `E` (an + octal, hexadecimal or binary prefix), and takes no sign. `operator""_r` stays `consteval` and turns each error + into its existing compile-time sentinel, so its refusals keep their messages. +- **Wider literals.** The literal mode's mantissa also widens from 64 to 128 bits, so `_r` accepts every literal + `Int128` holds. `docs/numbers.md`'s limits are updated. + +**A finding, fixed here: `from_decimal` and exponents beyond 18.** `Rational::from_decimal(mantissa, exponent)` +scales by `detail::pow10` (and the 128-bit `mul_pow10` built on it), which reach only 10^18, so `from_decimal(1, -19)` +and `from_decimal(1, 19)` are `Overflow` although both values fit a `Rational`. Both move to a 128-bit power of ten, +reaching 10^38, and a negative exponent first folds the mantissa's trailing zeros, so `from_decimal(10, -39)` is +1/10^38. The negative test +`rational_literal_exponent_out_of_range` keeps a literal that is still refused, and its comment gives the true +reason. + +**Tests.** + +- Every accepted form above, each value and places checked, also in `static_assert`s. +- Every refused form, each with its error. +- The widest mantissa `Int128` holds parses; one digit more is `Overflow`. +- `from_decimal(1, -19)` and `from_decimal(1, -38)` answer; `from_decimal(1, -39)` is `Overflow`. +- `_r`: the existing tests pass unchanged; a 20-digit literal now compiles; every existing negative test still fails + with its message. + +### 3.2 #23: `checked_transform` and `checked_combine` + +**Problem.** The callbacks of `transform` and `combine` (`measured.hpp:137`, `:165`) return a bare `Rational`. A +caller that must not throw can only use the throwing operators or replace an overflow with a made-up value. + +**Design.** New in `measured.hpp`, beside `checked_convert_to`: + +```cpp +template +[[nodiscard]] constexpr std::expected, ArithmeticError> +checked_transform(Measured value, F function) noexcept(std::is_nothrow_invocable_v); + +template +[[nodiscard]] constexpr std::expected, ArithmeticError> +checked_combine(Measured lhs, Measured rhs, F function) + noexcept(std::is_nothrow_invocable_v); +``` + +- `F` must return exactly `std::expected`. Anything else is refused by a library + `static_assert` naming the plain-value alternative: `formula: a checked_transform callback must return + std::expected; use transform for a callback that returns a Rational` (and likewise for + `checked_combine`). +- An absent input gives an absent result without calling `F`. An error from `F` is returned unchanged. + +**Tests.** Present and absent inputs; an error from `F` propagating; `checked_mul` as the callback; the `noexcept` +specification following `F`'s; a negative test for a callback returning `Rational`, expecting `must return +std::expected`. + +## 4. Final tasks + +- **Merge Lane B** into the branch; resolve `measured.hpp` and `CHANGELOG.md`. +- **Docs:** `docs/dimensions.md` (capacity, `checked_symbol`, ASCII keys, per-end bounds, `checked_within`, + `same_unit`), `docs/quantities.md` (`Places`, `checked_transform`, `checked_combine`), `docs/numbers.md` (run-time + parsing, the 128-bit literal mantissa), `README.md` (the serialising note). Code samples in the guides compile + where the repository already compiles them. +- **Finish:** as §5. + +## 5. Verification + +- **Per task:** `cl-debug`, plus the negative tests on `cl` and `clangcl-debug`. Each task is reviewed and fixed + until no finding is open, Minor findings included. +- **Finish:** + - a whole-branch review, its fixes, and a re-review; + - all eight presets green locally (`cl` and `clang-cl` debug and release; g++-14 and clang++-20 under WSL, + including UndefinedBehaviorSanitizer); + - Doxygen (warnings fail) and `mkdocs build --strict`; + - CI green on the pushed head; + - the pull request, whose body closes the six issues, marked ready for review. The owner merges. + +## 6. Breaking changes + +Each gets a CHANGELOG entry under *Changed* that says so in its first words. + +- `symbol()` is `consteval`: run-time text must use `checked_symbol`. +- A unit whose symbol is not ASCII must declare `asciiText` where a template takes it. +- `Bounds::present` is replaced by `lowPresent` and `highPresent`. +- `Unit`, `Symbol` and `Dimension` are larger (the capacity and the new member). + +## 7. Out of scope + +- Changing `Unit::operator==`. +- Writing ASCII keys into `render()`, traces or number text. +- Validating UTF-8 in symbols. +- Widening `Bounds` or `Unit` magnitude fields beyond 64 bits. +- A per-quantity bounds parameter: #25's run-time limits are answered by `checked_within`. +- A version bump or release. diff --git a/examples/dimensions_and_units.cpp b/examples/dimensions_and_units.cpp index 42899c9..7d2ffec 100644 --- a/examples/dimensions_and_units.cpp +++ b/examples/dimensions_and_units.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include @@ -149,8 +150,22 @@ int main() // `{}` of a BoundsCheck writes its describe() words. std::println("unbounded unit (litre) reports: {}", *unboundedVerdict); std::println("bounded gauge at 42%: {}", *boundedVerdict); - bool const boundsBehaveAsDocumented = - *unboundedVerdict == BoundsCheck::NotChecked && *boundedVerdict == BoundsCheck::WithinBounds; + + // Limits held at run time -- here a catalogue row with a minimum and no + // maximum -- need no unit to carry them, and either end may be absent. + std::optional const catalogueMinimum = Rational { 25 }; + Rational const gaugeReading = 42; + auto const catalogueVerdict = formula::checked_within(gaugeReading, catalogueMinimum, std::nullopt); + if (!catalogueVerdict) + { + std::println("checking the gauge against the catalogue: {}", catalogueVerdict.error()); + return 1; + } + std::println("gauge at 42% against a catalogue minimum of 25%: {}", *catalogueVerdict); + + bool const boundsBehaveAsDocumented = *unboundedVerdict == BoundsCheck::NotChecked + && *boundedVerdict == BoundsCheck::WithinBounds + && *catalogueVerdict == BoundsCheck::WithinBounds; // ---- 8. A base dimension the SI does not have: money ---- // diff --git a/include/formula-cpp/binning.hpp b/include/formula-cpp/binning.hpp index 9a53cb6..988da49 100644 --- a/include/formula-cpp/binning.hpp +++ b/include/formula-cpp/binning.hpp @@ -144,6 +144,7 @@ struct BinnedNode: SeriesNodeBase detail::RequireBinnedKeyMatches, std::true_type>::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The observations counted. No `{}` initialiser, deliberately: see /// `Corrections` (`lookup.hpp`). diff --git a/include/formula-cpp/conformity.hpp b/include/formula-cpp/conformity.hpp index ca303ed..78cf260 100644 --- a/include/formula-cpp/conformity.hpp +++ b/include/formula-cpp/conformity.hpp @@ -347,6 +347,7 @@ struct Conformity detail::RequireConformityUnitMatches, std::true_type>::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The series judged. S subject; diff --git a/include/formula-cpp/critical_value.hpp b/include/formula-cpp/critical_value.hpp index 62ed0c5..ee9bbd5 100644 --- a/include/formula-cpp/critical_value.hpp +++ b/include/formula-cpp/critical_value.hpp @@ -380,6 +380,7 @@ struct SampleSizeLookupNode: NodeBase static_assert(detail::RequireSampleCountScalar::value); static_assert(detail::RequireSampleCountInOne::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// One value per declared size, in `unit`, in the table's order -- the /// table's contents, and `Corrections` for that type's reason: a short diff --git a/include/formula-cpp/curve.hpp b/include/formula-cpp/curve.hpp index a34d8e9..b3cfc94 100644 --- a/include/formula-cpp/curve.hpp +++ b/include/formula-cpp/curve.hpp @@ -109,6 +109,7 @@ struct DomainNode: SeriesNodeBase static_assert(detail::RequireDomainNotEmpty::value); static_assert(RequireValidBreakpointTable::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The unit the points are declared in. static constexpr Unit unit = U; diff --git a/include/formula-cpp/detail/checked_int.hpp b/include/formula-cpp/detail/checked_int.hpp index d588e62..9ba3155 100644 --- a/include/formula-cpp/detail/checked_int.hpp +++ b/include/formula-cpp/detail/checked_int.hpp @@ -8,15 +8,15 @@ /// denominator in. The sum and the difference are formed on the two words' /// bit patterns; the product's overflow check is the compiler's own 128-bit /// integer where it has one, and portable code on the two words elsewhere -/// (`int128.hpp`). +/// (`int128.hpp`). The powers of ten up to 10^38 that `from_decimal`'s +/// exponent spans are 128-bit too. /// - **64 bits**, `Int`, for what stays 64-bit: the powers of ten up to 10^18 -/// that rounding's decimal places and `from_decimal`'s exponent span, the -/// `_r` literal's mantissa, and narrowing a value to the 64-bit fields of -/// `Band`, `Breakpoint` and a `Unit`'s magnitude (a rounded root's unit -/// scale, a trace's unit quotient). MSVC has no __builtin_*_overflow, and -/// its equivalents are not constexpr, so these checks are -/// written in portable C++ and used on every compiler. Optimisers -/// recognise these idioms. +/// that rounding's decimal places span, and narrowing a value to the +/// 64-bit fields of `Band`, `Breakpoint` and a `Unit`'s magnitude (a +/// rounded root's unit scale, a trace's unit quotient). MSVC has no +/// __builtin_*_overflow, and its equivalents are not constexpr, +/// so these checks are written in portable C++ and used on every compiler. +/// Optimisers recognise these idioms. /// /// **The overflow census.** This repository's own census programs are compiled /// with `FORMULA_OVERFLOW_CENSUS` defined. The macro is internal to them: it @@ -381,14 +381,26 @@ struct WideDivMod return u128_decimal(magnitude(operandValue)).length; } -/// `operandValue * 10^exponent`, for the exponents 0 to 18 `mul_pow10` takes -/// on 64 bits, or nothing on overflow or an exponent out of that range. +/// `10^exponent` on 128 bits, for `0 <= exponent <= 38`; `nullopt` otherwise. 10^38 is the largest power of ten +/// `Int128` holds. +[[nodiscard]] constexpr std::optional pow10_wide(int exponent) noexcept +{ + if (exponent < 0 || exponent > 38) + return std::nullopt; + Int128 power { 1 }; + for (int multiplied = 0; multiplied < exponent; ++multiplied) + power = power * Int128 { 10 }; + return power; +} + +/// `operandValue * 10^exponent`, for the exponents 0 to 38 `pow10_wide` +/// takes, or nothing on overflow or an exponent out of that range. [[nodiscard]] constexpr std::optional mul_pow10(Int128 operandValue, int exponent) noexcept { - std::optional const powerOfTen = pow10(exponent); + std::optional const powerOfTen = pow10_wide(exponent); if (!powerOfTen) return std::nullopt; - return mul_checked_or_none(operandValue, Int128 { *powerOfTen }); + return mul_checked_or_none(operandValue, *powerOfTen); } } // namespace formula::detail diff --git a/include/formula-cpp/dimension.hpp b/include/formula-cpp/dimension.hpp index 2e10941..8ba7e3d 100644 --- a/include/formula-cpp/dimension.hpp +++ b/include/formula-cpp/dimension.hpp @@ -6,9 +6,12 @@ /// dimensions and up to four named ones, usable as a non-type template /// parameter so that a dimension is part of a type rather than a runtime tag. +#include + #include #include #include +#include #include #include @@ -182,11 +185,12 @@ namespace detail return exponentValue.denominator == 1; } -/// Bytes available for a unit symbol, including the terminator. Enough for the -/// UTF-8 spellings that occur in practice: `m3`, `°C` (3 bytes), `µm` (3). A -/// symbol that does not fit is a compile error (see `symbol()`), never a -/// silent truncation; bump this deliberately if a real symbol ever needs more. -inline constexpr std::size_t SymbolCapacity = 16; +/// Bytes available for a unit symbol, including the terminator: 31 usable. +/// Enough for compound laboratory units such as `µmol/(L·min·kg)` (18 bytes +/// of UTF-8). A literal that does not fit is a compile error (see +/// `symbol()`), and run-time text that does not fit is refused by +/// `checked_symbol()`; neither ever truncates. +inline constexpr std::size_t SymbolCapacity = 32; /// A fixed-capacity symbol. An array of a structural type is structural, which a /// `std::string_view` is not -- and unlike a `FixedString` template this keeps @@ -213,10 +217,11 @@ namespace detail /// when it goes wrong. Truncating instead of refusing would let two /// distinct symbols collapse into the same `Symbol` object and therefore /// the same NTTP type, and could split a multi-byte UTF-8 character in - /// half. Calling this makes the enclosing expression a non-constant one, - /// so the mistake is a compile error at the point of use. Defined, not - /// merely declared, because a runtime call must still link; reaching it at - /// runtime is a programming error with no recovery. + /// half. Its body is reached only during constant evaluation, where + /// reaching a non-`constexpr` function is the refusal: the enclosing + /// expression stops being a constant one, so the mistake is a compile + /// error at the point of use that names this function. `symbol()` is + /// `consteval`, so no run-time call to it exists. [[noreturn]] inline void formula_unit_symbol_too_long() { std::abort(); @@ -226,8 +231,10 @@ namespace detail /// Builds a Symbol from a byte string. Refuses -- see /// `formula_unit_symbol_too_long` -- rather than truncating when the text does /// not fit in `SymbolCapacity` bytes including the terminator; every symbol -/// shipped by this library is well within the limit. -[[nodiscard]] constexpr Symbol symbol(char const* spelling) noexcept +/// shipped by this library is well within the limit. `consteval`, so the +/// spelling must be a constant expression; a spelling that only exists at run +/// time goes through `checked_symbol()`, which reports a refusal instead. +[[nodiscard]] consteval Symbol symbol(char const* spelling) noexcept { Symbol built {}; std::size_t characterIndex = 0; @@ -250,9 +257,10 @@ namespace detail /// text is a legal initialiser that leaves no room for a terminator. Handing /// that to `std::string_view { value.characters }` reads until it happens to /// find a zero somewhere after the array. Measured on a `Symbol` followed by -/// seven bytes of padding: 23 characters returned from a 16-byte array, the -/// neighbours included. A symbol built by `symbol()` is always terminated, but -/// this function cannot assume its argument came from there. +/// seven bytes of padding: 23 characters returned from what was then a +/// 16-byte array, the neighbours included. A symbol built by `symbol()` or +/// `checked_symbol()` is always terminated, but this function cannot assume +/// its argument came from there. [[nodiscard]] constexpr std::string_view view(Symbol const& unitSymbol) noexcept { std::size_t symbolLength = 0; @@ -268,6 +276,51 @@ namespace detail /// first, then call `view()` on that. std::string_view view(Symbol&&) = delete; +/// Why `checked_symbol` or `checked_ascii_symbol` refused a spelling. +enum class SymbolError : std::uint8_t +{ + /// The spelling does not fit `SymbolCapacity` bytes, terminator included. + TooLong, + /// The spelling contains a NUL byte, which would end the stored symbol early. + EmbeddedNull, + /// The spelling holds a byte outside printable ASCII (0x20 to 0x7E); only `checked_ascii_symbol` asks. + NotAscii, +}; + +/// @p symbolError in prose, for an error message. +[[nodiscard]] constexpr std::string_view describe(SymbolError symbolError) noexcept +{ + switch (symbolError) + { + case SymbolError::TooLong: return "the symbol does not fit SymbolCapacity bytes, terminator included"; + case SymbolError::EmbeddedNull: return "the symbol contains a NUL byte"; + case SymbolError::NotAscii: return "the symbol holds a byte outside printable ASCII"; + } + return "unknown symbol error"; +} + +namespace detail +{ +template <> +inline constexpr bool formats_by_describe = true; +} // namespace detail + +/// Builds a Symbol from run-time text -- a catalogue row, a configuration file -- byte for byte. Refuses text that +/// does not fit or that holds a NUL; never truncates, never aborts. UTF-8 is not validated: a symbol is bytes. +[[nodiscard]] constexpr std::expected checked_symbol(std::string_view spelling) noexcept +{ + if (spelling.size() + 1 > SymbolCapacity) + return std::unexpected { SymbolError::TooLong }; + Symbol built {}; + for (std::size_t characterIndex = 0; characterIndex < spelling.size(); ++characterIndex) + { + if (spelling[characterIndex] == '\0') + return std::unexpected { SymbolError::EmbeddedNull }; + built.characters[characterIndex] = spelling[characterIndex]; + } + return built; +} + /// How many named base dimensions one `Dimension` can hold at once: a tariff in /// euros per kilowatt-hour needs one, an exchange rate between two currencies /// two. A product that would need a fifth is a compile error -- see diff --git a/include/formula-cpp/escape.hpp b/include/formula-cpp/escape.hpp index 3586e3f..2b5b04e 100644 --- a/include/formula-cpp/escape.hpp +++ b/include/formula-cpp/escape.hpp @@ -88,6 +88,7 @@ struct NumericValueNode: NodeBase "NUL bytes defeats the only safeguard this escape hatch has"); static_assert(detail::RequireEscapeUnitMatches::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The expression whose numeric value is taken. /// diff --git a/include/formula-cpp/expression.hpp b/include/formula-cpp/expression.hpp index 8f98caa..475af95 100644 --- a/include/formula-cpp/expression.hpp +++ b/include/formula-cpp/expression.hpp @@ -51,6 +51,7 @@ struct VarNode: NodeBase "no formula containing it can be trusted; the quantity appears in this " "diagnostic as the template argument of VarNode"); static_assert(detail::RequireNamedScaledScalar::unit>::value); + static_assert(detail::RequireAsciiKey::unit>::value); /// The quantity this node names -- the key an `Environment` is asked with. using quantity = Q; @@ -77,6 +78,7 @@ template struct ConstantNode: NodeBase { static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The coefficient, in terms of `unit`. Rational number {}; diff --git a/include/formula-cpp/lookup.hpp b/include/formula-cpp/lookup.hpp index 23d2bda..65107f6 100644 --- a/include/formula-cpp/lookup.hpp +++ b/include/formula-cpp/lookup.hpp @@ -767,7 +767,9 @@ struct BandedLookupNode: NodeBase static_assert(RequireValidBandTable::value); static_assert(detail::RequireLookupKeyMatches::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// One correction per band, stated in `unit` -- the table's *contents*, /// runtime state for the same reason `ConstantNode::number` is. See the @@ -1175,6 +1177,7 @@ struct ExactLookupNode: NodeBase static_assert(detail::RequireScopedEnumKey>::value); static_assert(RequireValidKeyTable::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// One correction per key, stated in `unit`, in the same order `keys` /// declares -- the table's *contents*, runtime state for the same reason @@ -1810,7 +1813,9 @@ struct InterpolatingLookupNode: NodeBase static_assert(RequireValidBreakpointTable::value); static_assert(detail::RequireLookupKeyMatches::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The value this table states at each breakpoint, in `unit`, in the same /// order `breakpoints` declares -- the table's *contents*, runtime state diff --git a/include/formula-cpp/measured.hpp b/include/formula-cpp/measured.hpp index c8d8669..4b89fb9 100644 --- a/include/formula-cpp/measured.hpp +++ b/include/formula-cpp/measured.hpp @@ -12,6 +12,7 @@ #include #include #include +#include namespace formula { @@ -170,6 +171,86 @@ template return Measured { function(lhs.value(), rhs.value()) }; } +namespace detail +{ + /// False: @p F cannot be called with @p Arguments, so it returns nothing at all. Kept apart from the + /// specialisation below so that `std::invoke_result_t` is never formed for such a callback, and the caller's own + /// `static_assert` speaks instead of the standard library's missing `type`. + template + struct ReturnsCheckedRational: std::false_type + { + }; + + /// Whether @p F, which can be called with @p Arguments, returns exactly `std::expected`. + template + struct ReturnsCheckedRational: + std::is_same, std::expected> + { + }; + + /// Whether @p F, called with @p Arguments, returns exactly `std::expected`; false + /// when it cannot be called with them. + template + inline constexpr bool returns_checked_rational = + ReturnsCheckedRational, F, Arguments...>::value; +} // namespace detail + +/// `transform` for a callback that can fail: @p function returns `std::expected` -- +/// `checked_mul` and its siblings, say -- and its error is returned unchanged. An absent measurement stays absent +/// without calling @p function. `noexcept` when @p function is, so one line of arithmetic on a measurement can be +/// written under a no-throw rule. +template +[[nodiscard]] constexpr std::expected, ArithmeticError> checked_transform(Measured measured, F function) + noexcept(std::is_nothrow_invocable_v) +{ + static_assert(std::is_invocable_v, + "formula: a checked_transform callback must be callable with a Rational"); + static_assert(!std::is_invocable_v || detail::returns_checked_rational, + "formula: a checked_transform callback must return std::expected; use " + "transform for a callback that returns a Rational"); + // A callback refused above never reaches the call: the branch below is discarded for it, so the build fails + // with the library's message alone. The else branch is therefore never part of a working build. + if constexpr (detail::returns_checked_rational) + { + if (measured.is_absent()) + return Measured {}; + std::expected const transformed = function(measured.value()); + if (!transformed) + return std::unexpected { transformed.error() }; + return Measured { *transformed }; + } + else + return Measured {}; +} + +/// `combine` for a callback that can fail, as `checked_transform` is for `transform`. Absent if either measurement +/// is absent, without calling @p function. +template +[[nodiscard]] constexpr std::expected, ArithmeticError> checked_combine(Measured lhs, + Measured rhs, + F function) + noexcept(std::is_nothrow_invocable_v) +{ + static_assert(std::is_invocable_v, + "formula: a checked_combine callback must be callable with two Rationals"); + static_assert(!std::is_invocable_v || detail::returns_checked_rational, + "formula: a checked_combine callback must return std::expected; use " + "combine for a callback that returns a Rational"); + // A callback refused above never reaches the call: the branch below is discarded for it, so the build fails + // with the library's message alone. The else branch is therefore never part of a working build. + if constexpr (detail::returns_checked_rational) + { + if (lhs.is_absent() || rhs.is_absent()) + return Measured {}; + std::expected const combined = function(lhs.value(), rhs.value()); + if (!combined) + return std::unexpected { combined.error() }; + return Measured { *combined }; + } + else + return Measured {}; +} + namespace detail { @@ -235,6 +316,17 @@ template return checked_within_bounds(value.value(), Describe::unit); } +/// Checks a measurement against limits held at run time, as `checked_within` on a `Rational`; an absent +/// measurement is `BoundsCheck::NotMeasured`. +template +[[nodiscard]] constexpr std::expected checked_within( + Measured measured, std::optional lowEnd, std::optional highEnd) noexcept +{ + if (measured.is_absent()) + return BoundsCheck::NotMeasured; + return checked_within(measured.value(), lowEnd, highEnd); +} + /// Rounds a measurement to the precision its quantity's unit declares. template [[nodiscard]] constexpr std::expected, ArithmeticError> checked_round_to_declared( @@ -260,6 +352,17 @@ template return detail::or_throw(checked_within_bounds(measured)); } +/// Throwing spelling of `checked_within` on a measurement, for callers who would only rethrow. +/// +/// An absent measurement is `BoundsCheck::NotMeasured`, as there. Throws +/// `ArithmeticException` where `checked_within` returns an error. +template +[[nodiscard]] constexpr BoundsCheck within(Measured measured, std::optional lowEnd, + std::optional highEnd) +{ + return detail::or_throw(checked_within(measured, lowEnd, highEnd)); +} + /// Throwing spelling of `checked_round_to_declared`, for callers who would only rethrow. /// /// An absent measurement stays absent, as there. Throws `ArithmeticException` diff --git a/include/formula-cpp/method.hpp b/include/formula-cpp/method.hpp index fca3839..b5ce456 100644 --- a/include/formula-cpp/method.hpp +++ b/include/formula-cpp/method.hpp @@ -1083,6 +1083,7 @@ template class RoundingRule { static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); public: /// The unit the rounding happens in -- see `rounding_node.hpp` for why a diff --git a/include/formula-cpp/number_text.hpp b/include/formula-cpp/number_text.hpp index 143b491..cd12adc 100644 --- a/include/formula-cpp/number_text.hpp +++ b/include/formula-cpp/number_text.hpp @@ -38,7 +38,7 @@ namespace formula /// Bytes of text a `NumberText` can hold. The longest text this header /// spells is a fraction -- a sign, a 39-digit numerator, a slash, a 39-digit -/// denominator, a space and a unit symbol of `SymbolCapacity` bytes -- of 97 +/// denominator, a space and a unit symbol of `SymbolCapacity` bytes -- of 113 /// bytes, and a `static_assert` below keeps it within this. A fraction is /// never marked approximate, and the longest marked decimal, at 18 places, is /// shorter. A value in a dimensioned unit with no symbol is followed by its diff --git a/include/formula-cpp/observations.hpp b/include/formula-cpp/observations.hpp index 1837bcd..42afdfd 100644 --- a/include/formula-cpp/observations.hpp +++ b/include/formula-cpp/observations.hpp @@ -59,6 +59,7 @@ struct ObservationsVarNode: ObservationsNodeBase "no formula containing it can be trusted; the quantity appears in this " "diagnostic as the template argument of ObservationsVarNode"); static_assert(detail::RequireNamedScaledScalar::unit>::value); + static_assert(detail::RequireAsciiKey::unit>::value); /// The quantity this node names -- the key an `Environment` is asked with. using quantity = Q; diff --git a/include/formula-cpp/opaque.hpp b/include/formula-cpp/opaque.hpp index 10eaaec..befc7c6 100644 --- a/include/formula-cpp/opaque.hpp +++ b/include/formula-cpp/opaque.hpp @@ -1028,6 +1028,7 @@ struct RoundedOpaqueOutputNode: NodeBase !OpaqueOutputNode::refused && U.dimension == OpaqueOutputNode::dimension>::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The call whose output this is. Evaluating this node evaluates it whole. Call call; diff --git a/include/formula-cpp/overlay.hpp b/include/formula-cpp/overlay.hpp index 02707e3..36264cb 100644 --- a/include/formula-cpp/overlay.hpp +++ b/include/formula-cpp/overlay.hpp @@ -697,6 +697,7 @@ template struct RoundingOverride { static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The rule that replaces the method's own, as `rounding_rule<>()` would /// spell it. diff --git a/include/formula-cpp/quantity.hpp b/include/formula-cpp/quantity.hpp index c56f0d5..a61a941 100644 --- a/include/formula-cpp/quantity.hpp +++ b/include/formula-cpp/quantity.hpp @@ -53,9 +53,9 @@ namespace formula /// names a quantity -- a function declaration taking `Measured` -- can /// say `struct Rise;` for a struct quantity, and must include the /// alias's declaration. -/// - **Two aliases with all four arguments equal are one type.** `using A = +/// - **Two aliases with all their arguments equal are one type.** `using A = /// Quantity;` and a `using B` with the -/// same four arguments declare one quantity under two names, and nothing +/// same arguments declare one quantity under two names, and nothing /// can object: there is only one type, and naming it twice is not an error /// anywhere in C++. Give every alias its own tag. Two structs never /// collapse this way, whatever their bases. @@ -93,7 +93,28 @@ namespace formula /// That spelling, with `dim::Mass` against `unit::Litre`, compiled without a /// diagnostic on every compiler it was tried on. `dimension` below is derived, /// so the contradiction cannot be written. -template +/// +/// **A quantity may declare its own decimal places.** The fifth parameter, +/// `Places`, defaults to the places `U` declares. Given, it replaces them for +/// this quantity alone, so two quantities in one unit can be read to different +/// precision without a second unit: +/// +/// struct FineCurrent: +/// formula::Quantity +/// { +/// }; +/// +/// with `Milliampere` declared to whole milliamperes. Everything that reads a +/// quantity's declared places -- `checked_round_to_declared`, `number_text`, +/// `std::format`, a trace -- reads them off `unit`, which carries `Places`. +/// `FineCurrent::unit == Milliampere` is then false, since `==` compares every +/// member; `same_unit(FineCurrent::unit, Milliampere)` is true. +template struct Quantity { /// The quantity's own type, for anything that needs to name it. @@ -105,8 +126,9 @@ struct Quantity /// What the quantity means, in words, for generated documentation. static constexpr std::string_view description = Description.view(); - /// The unit its values are expressed in. - static constexpr Unit unit = U; + /// The unit its values are expressed in: `U`, with its declared places + /// replaced when the quantity declares its own. + static constexpr Unit unit = detail::with_decimals(U, Places); /// Derived from the unit -- see the note above about why it is not a /// parameter of its own. @@ -120,9 +142,9 @@ namespace detail /// for an alias, or its base, through the derived-to-base conversion, for /// a struct -- which is how `Describe` learns a type's metadata without the /// type having to repeat it. Measured on cl, clang-cl and clang++. - template - auto quantity_base_of(Quantity const&) - -> Quantity; + template + auto quantity_base_of(Quantity const&) + -> Quantity; template concept DeclaresQuantity = requires(T const& value) { quantity_base_of(value); }; @@ -234,11 +256,23 @@ namespace detail struct RequireDescribedUnitNamesItsScale: RequireNamedScaledScalar::unit> { }; + + /// `RequireAsciiKey` of a described type's unit, asked only once the type is described, as above. + template > + struct RequireDescribedUnitHasAsciiKey: std::true_type + { + }; + + template + struct RequireDescribedUnitHasAsciiKey: RequireAsciiKey::unit> + { + }; } // namespace detail /// Fails to compile, in our own words, when `T` declares no metadata, -/// declares metadata whose `dimension` contradicts its own `unit`, or declares -/// it in a dimensionless unit with a scale and no symbol. +/// declares metadata whose `dimension` contradicts its own `unit`, declares +/// it in a dimensionless unit with a scale and no symbol, or declares it in a +/// unit whose symbol is not ASCII and that has no ASCII key. /// /// The counterpart to `Describe` being silent: somewhere has to say what to do /// about it, and a bare "no member named 'symbol'" does not. Same shape as @@ -264,6 +298,7 @@ struct RequireDescribed "disagrees mislabels every value read through it -- derive Describe::dimension " "from Describe::unit.dimension instead of stating it independently"); static_assert(detail::RequireDescribedUnitNamesItsScale::value); + static_assert(detail::RequireDescribedUnitHasAsciiKey::value); /// Always `true` once reached -- the `static_assert`s above already failed /// compilation otherwise. Present so `::value` is the spelling that diff --git a/include/formula-cpp/rational.hpp b/include/formula-cpp/rational.hpp index beadd2b..5ff0ba4 100644 --- a/include/formula-cpp/rational.hpp +++ b/include/formula-cpp/rational.hpp @@ -25,6 +25,7 @@ #include #include #include +#include #include namespace formula @@ -125,11 +126,20 @@ class Rational } /// `mantissa * 10^exponent`, exactly. The preferred way to write a decimal: - /// `from_decimal(45, -2)` is 9/20, not the nearest double to 0.45. + /// `from_decimal(45, -2)` is 9/20, not the nearest double to 0.45. A + /// negative exponent first folds the mantissa's trailing zeros, so + /// `from_decimal(10, -39)` is 1/10^38. `Overflow` when the exponent, after + /// that folding, lies outside -38 to 38, or when the scaled value does not + /// fit: `from_decimal(25, -39)` is refused, though 1/(4 * 10^37) would fit. [[nodiscard]] static constexpr std::expected from_decimal(Int mantissa, int exponent) noexcept { if (mantissa == 0) return Rational {}; + while (exponent < 0 && mantissa % 10 == 0) + { + mantissa /= 10; + ++exponent; + } if (exponent >= 0) { std::optional const scaledMantissa = detail::mul_pow10(mantissa, exponent); @@ -137,12 +147,18 @@ class Rational return std::unexpected { ArithmeticError::Overflow }; return Rational { *scaledMantissa }; } - std::optional const powerOfTen = detail::pow10(-exponent); + if (exponent < -38) // before negating: -INT_MIN would overflow int + return std::unexpected { ArithmeticError::Overflow }; + std::optional const powerOfTen = detail::pow10_wide(-exponent); if (!powerOfTen) return std::unexpected { ArithmeticError::Overflow }; return make(mantissa, *powerOfTen); } + /// The value `parse_decimal_text` reads from @p spelling: `"2.400"` is + /// 12/5. Text is refused as `parse_decimal_text` describes. + [[nodiscard]] static constexpr std::expected from_decimal_text(std::string_view spelling) noexcept; + /// The exact value of the double, which is a dyadic rational. Usually not /// what a norm means: 0.45 as a double is not 9/20. Prefer from_decimal, or /// rational_from_double when the input genuinely is a measured double. @@ -531,94 +547,206 @@ constexpr Rational& operator/=(Rational& leftOperand, Rational rightOperand) /// state which number it was. inline constexpr Rational Pi { 245'850'922, 78'256'779 }; -namespace detail +/// A decimal read from text: its exact value, and the places it was typed to. +struct ParsedDecimal { - /// A `_r` literal whose exact value `Rational` cannot hold: too many - /// significant digits, or a denominator above `Int`'s range. Deliberately - /// not `constexpr`, like `formula_exponent_out_of_range` - /// (`dimension.hpp`): the literal operator is `consteval`, so reaching - /// this fails to compile and the diagnostic names it. - [[noreturn]] inline void formula_rational_literal_out_of_range() - { - std::abort(); - } + /// The exact value: `"2.400"` is 12/5. + Rational value; + /// The digits after the point, minus the exponent, never below 0: `"2.400"` is 3, `"2.4"` is 1, `"1.5e3"` is 0. + std::int32_t places; - /// A `_r` literal spelled as something other than a decimal: a - /// hexadecimal or binary integer, or an integer with a leading zero, - /// which C++ reads as octal. Same mechanism as above. - [[noreturn]] inline void formula_rational_literal_not_a_decimal() - { - std::abort(); - } + /// Equal when both the value and the places are. + constexpr bool operator==(ParsedDecimal const&) const noexcept = default; +}; - /// The exact value of a decimal literal's spelling: digits, an optional - /// fraction, an optional exponent, and digit separators. - consteval Rational rational_from_spelling(char const* spelling) +namespace detail +{ + /// Which spelling `parse_decimal` reads: run-time text, or a `_r` literal's spelling. + enum class DecimalSyntax : std::uint8_t + { + /// An optional sign, digits with at most one point, an optional exponent; leading zeros are fine. + Text, + /// What C++ hands a literal operator: no sign, digit separators (`'`), and no leading zero that C++ reads + /// as octal, hexadecimal or binary. + Literal, + }; + + /// The exact value of a decimal spelling, and its places as typed. `DomainError` for a spelling that is not a + /// decimal in @p syntax; otherwise refused as `parse_decimal_text` describes. The mantissa accumulates in 128 + /// bits; fractional zeros are deferred, so trailing ones cost nothing. + [[nodiscard]] constexpr std::expected parse_decimal(std::string_view spelling, + DecimalSyntax syntax) noexcept { + bool const literal = syntax == DecimalSyntax::Literal; std::size_t at = 0; - // Also true for an exponent: that is how `0x1E` gets past the leading guard, to be refused at its `x`. - bool const hasPoint = [&] { - for (std::size_t probe = 0; spelling[probe] != '\0'; ++probe) - if (spelling[probe] == '.' || spelling[probe] == 'e' || spelling[probe] == 'E') - return true; - return false; - }(); - if (spelling[0] == '0' && spelling[1] != '\0' && !hasPoint) - formula_rational_literal_not_a_decimal(); // 017, 0x1F, 0b101 - Int mantissa = 0; - int decimalScale = 0; // decimal exponent the digits carry - int pendingZeros = 0; // fractional zeros not yet multiplied in + bool negative = false; + if (!literal && at < spelling.size() && (spelling[at] == '+' || spelling[at] == '-')) + { + negative = spelling[at] == '-'; + ++at; + } + if (literal && spelling.size() > 1 && spelling[0] == '0' + && spelling.find_first_of(".eE") == std::string_view::npos) + return std::unexpected { ArithmeticError::DomainError }; // 017, 0x1F, 0b101 + Rational::Int mantissa { 0 }; + std::int64_t decimalScale = 0; // decimal exponent the digits carry + std::int64_t pendingZeros = 0; // fractional zeros not yet multiplied in + std::int64_t fractionDigits = 0; // digits after the point, as typed bool inFraction = false; - for (; spelling[at] != '\0' && spelling[at] != 'e' && spelling[at] != 'E'; ++at) + bool sawDigit = false; + for (; at < spelling.size() && spelling[at] != 'e' && spelling[at] != 'E'; ++at) { char const symbolAt = spelling[at]; - if (symbolAt == '\'') + if (literal && symbolAt == '\'') continue; if (symbolAt == '.') { + if (inFraction) + return std::unexpected { ArithmeticError::DomainError }; inFraction = true; continue; } - if (symbolAt < '0' || symbolAt > '9') // the only refusal of a hexadecimal spelling holding e or E, such as 0x1E - formula_rational_literal_not_a_decimal(); + if (symbolAt < '0' || symbolAt > '9') // also how a literal such as 0x1E, past the leading guard, is refused + return std::unexpected { ArithmeticError::DomainError }; + sawDigit = true; int const digitValue = symbolAt - '0'; + if (inFraction) + ++fractionDigits; if (inFraction && digitValue == 0) { ++pendingZeros; continue; } - for (int zero = 0; zero <= pendingZeros; ++zero) // the zeros, then this digit's place + for (std::int64_t zero = 0; zero <= pendingZeros; ++zero) // the zeros, then this digit's place { - int const placed = zero == pendingZeros ? digitValue : 0; - if (mantissa > (IntMax - placed) / 10) - formula_rational_literal_out_of_range(); - mantissa = mantissa * 10 + placed; + std::optional const shifted = mul_checked_or_none(mantissa, Rational::Int { 10 }); + if (!shifted) + return std::unexpected { ArithmeticError::Overflow }; + std::optional const placed = + add_checked_or_none(*shifted, Rational::Int { zero == pendingZeros ? digitValue : 0 }); + if (!placed) + return std::unexpected { ArithmeticError::Overflow }; + mantissa = *placed; } if (inFraction) decimalScale -= pendingZeros + 1; pendingZeros = 0; } - if (spelling[at] == 'e' || spelling[at] == 'E') + if (!sawDigit) + return std::unexpected { ArithmeticError::DomainError }; + std::int64_t exponentValue = 0; + if (at < spelling.size()) // at an e or E { ++at; - bool const negative = spelling[at] == '-'; - if (spelling[at] == '-' || spelling[at] == '+') + bool exponentNegative = false; + if (at < spelling.size() && (spelling[at] == '+' || spelling[at] == '-')) + { + exponentNegative = spelling[at] == '-'; ++at; - int written = 0; - for (; spelling[at] != '\0'; ++at) + } + bool sawExponentDigit = false; + for (; at < spelling.size(); ++at) { - if (spelling[at] == '\'') + if (literal && spelling[at] == '\'') continue; - written = written * 10 + (spelling[at] - '0'); - if (written > 1'000) - formula_rational_literal_out_of_range(); + if (spelling[at] < '0' || spelling[at] > '9') + return std::unexpected { ArithmeticError::DomainError }; + sawExponentDigit = true; + // Bounded while read, so it cannot overflow: past what places can state, it stops growing, and + // that is refused below. + if (exponentValue <= std::numeric_limits::max()) + exponentValue = exponentValue * 10 + (spelling[at] - '0'); } - decimalScale += negative ? -written : written; + if (!sawExponentDigit) + return std::unexpected { ArithmeticError::DomainError }; + if (exponentNegative) + exponentValue = -exponentValue; } - std::expected const made = Rational::from_decimal(mantissa, decimalScale); + std::int64_t const placesTyped = fractionDigits - exponentValue; + if (placesTyped > std::numeric_limits::max()) + return std::unexpected { ArithmeticError::Overflow }; + std::int32_t const places = placesTyped < 0 ? 0 : static_cast(placesTyped); + if (mantissa == 0) + return ParsedDecimal { Rational {}, places }; + if (exponentValue > 1'000 || exponentValue < -1'000) // as `_r` has always refused + return std::unexpected { ArithmeticError::Overflow }; + std::int64_t const totalScale = decimalScale + exponentValue; + if (totalScale > 1'000 || totalScale < -1'000) // keeps the narrowing to int safe; from_decimal decides the rest + return std::unexpected { ArithmeticError::Overflow }; + std::expected const made = + Rational::from_decimal(mantissa, static_cast(totalScale)); if (!made) + return std::unexpected { made.error() }; + if (!negative) + return ParsedDecimal { *made, places }; + // The mantissa is never negative, so its negation always fits. + std::expected const negated = checked_negate(*made); + if (!negated) + return std::unexpected { negated.error() }; + return ParsedDecimal { *negated, places }; + } +} // namespace detail + +/// Parses decimal text that arrives at run time -- a CSV import, a form field, a configuration value -- into its +/// exact value and the places it was typed to: `"2.400"` is 12/5 at 3 places, `"2.4"` 12/5 at 1. An optional sign, +/// digits with at most one point, an optional exponent (`e` or `E`, an optional sign, digits). `DomainError` for +/// anything else -- whitespace, a decimal comma, separators, `inf`, `nan`. `Overflow` for exactly these: +/// +/// - digits above 2^127 - 1 in magnitude, read as an integer (trailing zeros after the point aside); +/// - an exponent outside -1000 to 1000, for a value that is not zero: `"0.<1001 zeros>1e1002"` is refused, though +/// it is 1; +/// - a scale outside 10^-38 to 10^38 once trailing zeros fold: `"2.5e-38"` is refused, though 1/(4 * 10^37) would +/// fit; +/// - a scaled whole number above 2^127 - 1: `"2e38"`; +/// - places as typed above `INT32_MAX`, even for zero: `"0e-99999999999"`. +/// +/// The same parser reads `_r` literals. +[[nodiscard]] constexpr std::expected parse_decimal_text(std::string_view spelling) noexcept +{ + return detail::parse_decimal(spelling, detail::DecimalSyntax::Text); +} + +constexpr std::expected Rational::from_decimal_text(std::string_view spelling) noexcept +{ + std::expected const parsed = parse_decimal_text(spelling); + if (!parsed) + return std::unexpected { parsed.error() }; + return parsed->value; +} + +namespace detail +{ + /// A `_r` literal whose exact value `Rational` cannot hold: too many + /// significant digits, or a denominator above `Int`'s range. Deliberately + /// not `constexpr`, like `formula_exponent_out_of_range` + /// (`dimension.hpp`): the literal operator is `consteval`, so reaching + /// this fails to compile and the diagnostic names it. + [[noreturn]] inline void formula_rational_literal_out_of_range() + { + std::abort(); + } + + /// A `_r` literal spelled as something other than a decimal: a + /// hexadecimal or binary integer, or an integer with a leading zero, + /// which C++ reads as octal. Same mechanism as above. + [[noreturn]] inline void formula_rational_literal_not_a_decimal() + { + std::abort(); + } + + /// The exact value of a decimal literal's spelling: digits, an optional + /// fraction, an optional exponent, and digit separators. Read by + /// `parse_decimal`, the parser `parse_decimal_text` uses, in its literal + /// syntax; each refusal becomes its compile-time sentinel. + consteval Rational rational_from_spelling(char const* spelling) + { + std::expected const parsed = + parse_decimal(std::string_view { spelling }, DecimalSyntax::Literal); + if (!parsed && parsed.error() == ArithmeticError::DomainError) + formula_rational_literal_not_a_decimal(); + if (!parsed) formula_rational_literal_out_of_range(); - return *made; + return parsed->value; } } // namespace detail @@ -626,10 +754,11 @@ inline namespace literals { /// An exact decimal: `27.3_r` is 273/10, never the `double` nearest it. /// An exponent scales exactly (`1.5e-3_r` is 3/2000); `-27.3_r` is - /// `Rational`'s own negation. A spelling `Rational` cannot hold, or one - /// that is not a decimal (`0x1F_r`, and `017_r`, which C++ reads as - /// octal), fails to compile, naming `formula_rational_literal_out_of_range` - /// or `formula_rational_literal_not_a_decimal`. + /// `Rational`'s own negation. A spelling refused as `parse_decimal_text` + /// describes its `Overflow`, or one that is not a decimal (`0x1F_r`, and + /// `017_r`, which C++ reads as octal), fails to compile, naming + /// `formula_rational_literal_out_of_range` or + /// `formula_rational_literal_not_a_decimal`. consteval Rational operator""_r(char const* spelling) { return detail::rational_from_spelling(spelling); diff --git a/include/formula-cpp/rounded_root.hpp b/include/formula-cpp/rounded_root.hpp index 8c6d39a..467c1b0 100644 --- a/include/formula-cpp/rounded_root.hpp +++ b/include/formula-cpp/rounded_root.hpp @@ -153,6 +153,9 @@ namespace detail { if (radicandInUnitSquared.sign() < 0) return std::unexpected { ArithmeticError::DomainError }; + // As `checked_round` reports them, whichever path the root takes. + if (places.value > 18 || places.value < -18) + return std::unexpected { ArithmeticError::Overflow }; std::expected const exactRoot = checked_exact_nth_root(radicandInUnitSquared, 2); if (exactRoot.has_value()) @@ -282,6 +285,7 @@ struct RoundedRootNode: NodeBase static_assert(detail::RequireRootUnitMatches::value); static_assert(detail::RequireRootUnitWithoutOffset::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The expression whose square root is taken: a variance, a mean square, /// a sum of squared uncertainties. diff --git a/include/formula-cpp/rounding_node.hpp b/include/formula-cpp/rounding_node.hpp index 2b8bc00..7b4d895 100644 --- a/include/formula-cpp/rounding_node.hpp +++ b/include/formula-cpp/rounding_node.hpp @@ -50,6 +50,7 @@ struct RoundNode: NodeBase { static_assert(detail::RequireRoundingUnitMatches::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The expression being rounded. /// @@ -77,6 +78,7 @@ struct RoundSignificantNode: NodeBase { static_assert(detail::RequireRoundingUnitMatches::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The expression being rounded. /// diff --git a/include/formula-cpp/series.hpp b/include/formula-cpp/series.hpp index 3f3c9de..bb9ca4c 100644 --- a/include/formula-cpp/series.hpp +++ b/include/formula-cpp/series.hpp @@ -71,6 +71,7 @@ struct SeriesVarNode: SeriesNodeBase "no formula containing it can be trusted; the quantity appears in this " "diagnostic as the template argument of SeriesVarNode"); static_assert(detail::RequireNamedScaledScalar::unit>::value); + static_assert(detail::RequireAsciiKey::unit>::value); static_assert(N > 0, "formula: this series has no elements; a series is a value at each point of a method's domain, " "and a domain of no points has nothing to sum, round or trace -- the quantity appears in this " @@ -181,6 +182,7 @@ struct SeriesConstantNode: SeriesNodeBase "domain, and a domain of no points has nothing to sum, round or trace -- give it at least one " "value"); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The values. No `{}` initialiser, deliberately: see `Elements`. Elements elements; @@ -498,6 +500,7 @@ struct ElementwiseRoundNode: SeriesNodeBase detail::RequireRoundingUnitMatches, std::true_type>::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The series rounded. No `{}` initialiser, deliberately: see /// `Corrections` (`lookup.hpp`). diff --git a/include/formula-cpp/snap.hpp b/include/formula-cpp/snap.hpp index 82ca274..cdea49d 100644 --- a/include/formula-cpp/snap.hpp +++ b/include/formula-cpp/snap.hpp @@ -170,6 +170,7 @@ struct SnapNode: NodeBase static_assert(std::conditional_t<(Permitted.size() > 0), RequireValidBreakpointTable, std::true_type>::value); static_assert(std::conditional_t, std::true_type>::value); static_assert(detail::RequireNamedScaledScalar::value); + static_assert(detail::RequireAsciiKey::value); /// The expression whose value is snapped. No `{}` initialiser, /// deliberately: see `Corrections` (`lookup.hpp`). diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index a79c02e..3b16ce0 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -2189,14 +2189,12 @@ namespace detail /// name: the same dimension, factor, offset and symbol. Their declared /// decimals and bounds may differ -- two gram readings are grams whatever /// precision each was declared at -- which is why this is not - /// `Unit::operator==`. + /// `Unit::operator==`. It ignores the ASCII key too, which is never + /// displayed: this compares what a unit shows, where `same_unit` also + /// compares its key. [[nodiscard]] constexpr bool same_scale_and_symbol(Unit const& leftUnit, Unit const& rightUnit) noexcept { - return leftUnit.dimension == rightUnit.dimension && leftUnit.magnitudeNumerator == rightUnit.magnitudeNumerator - && leftUnit.magnitudeDenominator == rightUnit.magnitudeDenominator - && leftUnit.offsetNumerator == rightUnit.offsetNumerator - && leftUnit.offsetDenominator == rightUnit.offsetDenominator - && view(leftUnit.symbolText) == view(rightUnit.symbolText); + return same_scale(leftUnit, rightUnit) && view(leftUnit.symbolText) == view(rightUnit.symbolText); } /// Whether `RecordingSink` records a step of its own for @p N, a single @@ -2830,15 +2828,46 @@ namespace detail || unitSymbol.find("\xe2\x8b\x85") != std::string_view::npos; // U+22C5 dot operator } + /// @p overText and @p underText joined by a slash, the divisor bracketed + /// when it is more than one unit word (`compound_unit_symbol`): the + /// spelling of a quotient unit, its symbol and its key alike. Empty when + /// either is empty or already holds a slash (`m/s/s` reads two ways), or + /// when the joined text, brackets included, would not fit a `Symbol`. + [[nodiscard]] inline std::optional quotient_unit_text(std::string_view overText, + std::string_view underText) noexcept + { + bool const bracketed = compound_unit_symbol(underText); + if (overText.empty() || underText.empty() || overText.find('/') != std::string_view::npos + || underText.find('/') != std::string_view::npos + || overText.size() + 1 + underText.size() + (bracketed ? 2 : 0) + 1 > SymbolCapacity) + return std::nullopt; + Symbol joined {}; + std::size_t written = 0; + for (char const spelt: overText) + joined.characters[written++] = spelt; + joined.characters[written++] = '/'; + if (bracketed) + joined.characters[written++] = '('; + for (char const spelt: underText) + joined.characters[written++] = spelt; + if (bracketed) + joined.characters[written++] = ')'; + return joined; + } + /// The quotient of two units, `N/mm` from `N` and `mm`: its magnitude the /// quotient of theirs and its symbol theirs joined by a slash, the /// denominator bracketed when it is more than one unit word - /// (`mm/(mPa.s)`, `compound_unit_symbol`). Empty when either has an + /// (`mm/(mPa.s)`, `quotient_unit_text`). When either declares an ASCII + /// key, the quotient's key is their `view_ascii` keys joined the same way + /// -- `um/s` for micrometres per second, whose symbol keeps its micro + /// sign -- and refused the same way; when neither does, the quotient + /// declares none, and its symbol is its key. Empty when either has an /// offset, has no symbol, is dimensionless -- a ratio is not a percentage /// because some input was one, as `opaque_output_unit` rules for a /// dimensionless output -- or already holds a slash (`m/s/s` reads two - /// ways), or when the symbol, the magnitude or the dimension would not - /// fit -- the dimension when the quotient's own named base dimensions + /// ways), or when the symbol, the key, the magnitude or the dimension would + /// not fit -- the dimension when the quotient's own named base dimensions /// would number more than `NamedBaseCapacity`: a name both units carry /// counts once, or not at all when its exponents cancel. That is judged /// with `merged_dimension`, not `operator/`, whose guard aborts when @@ -2854,12 +2883,13 @@ namespace detail if (over.offsetNumerator != 0 || under.offsetNumerator != 0 || over.dimension == dim::Scalar || under.dimension == dim::Scalar) return std::nullopt; - std::string_view const overSymbol = view(over.symbolText); - std::string_view const underSymbol = view(under.symbolText); - bool const bracketed = compound_unit_symbol(underSymbol); - if (overSymbol.empty() || underSymbol.empty() || overSymbol.find('/') != std::string_view::npos - || underSymbol.find('/') != std::string_view::npos - || overSymbol.size() + 1 + underSymbol.size() + (bracketed ? 2 : 0) + 1 > SymbolCapacity) + std::optional const quotientSymbol = quotient_unit_text(view(over.symbolText), view(under.symbolText)); + if (!quotientSymbol) + return std::nullopt; + bool const keyed = !view(over.asciiText).empty() || !view(under.asciiText).empty(); + std::optional const quotientKey = + keyed ? quotient_unit_text(view_ascii(over), view_ascii(under)) : std::optional { Symbol {} }; + if (!quotientKey) return std::nullopt; std::expected const magnitude = RepTraits::divide(Rational { over.magnitudeNumerator, over.magnitudeDenominator }, @@ -2874,21 +2904,12 @@ namespace detail MergedDimension const quotientDimension = merged_dimension(over.dimension, under.dimension, true); if (!quotientDimension.fits) return std::nullopt; - Unit quotientUnit { .dimension = quotientDimension.dimension, - .magnitudeNumerator = *magnitudeTop, - .magnitudeDenominator = *magnitudeBottom, - .decimals = over.decimals < under.decimals ? under.decimals : over.decimals }; - std::size_t written = 0; - for (char const spelt: overSymbol) - quotientUnit.symbolText.characters[written++] = spelt; - quotientUnit.symbolText.characters[written++] = '/'; - if (bracketed) - quotientUnit.symbolText.characters[written++] = '('; - for (char const spelt: underSymbol) - quotientUnit.symbolText.characters[written++] = spelt; - if (bracketed) - quotientUnit.symbolText.characters[written++] = ')'; - return quotientUnit; + return Unit { .dimension = quotientDimension.dimension, + .magnitudeNumerator = *magnitudeTop, + .magnitudeDenominator = *magnitudeBottom, + .symbolText = *quotientSymbol, + .asciiText = *quotientKey, + .decimals = over.decimals < under.decimals ? under.decimals : over.decimals }; } /// The unit an opaque output of @p dimension is shown in, from the units diff --git a/include/formula-cpp/unit.hpp b/include/formula-cpp/unit.hpp index 0195acb..859c034 100644 --- a/include/formula-cpp/unit.hpp +++ b/include/formula-cpp/unit.hpp @@ -19,16 +19,71 @@ #include #include +#include #include namespace formula { -/// Optional validity range, in the unit's own scale, as exact rationals. +namespace detail +{ + /// Whether every byte of @p spelling is printable ASCII, 0x20 to 0x7E. The empty text is. + [[nodiscard]] constexpr bool is_printable_ascii(std::string_view spelling) noexcept + { + for (char const byteAt: spelling) + if (static_cast(byteAt) < 0x20U || static_cast(byteAt) > 0x7EU) + return false; + return true; + } +} // namespace detail + +/// Builds an ASCII key from run-time text: `checked_symbol`'s refusals, and `SymbolError::NotAscii` for any byte +/// outside printable ASCII. +[[nodiscard]] constexpr std::expected checked_ascii_symbol(std::string_view spelling) noexcept +{ + // checked_symbol first: a NUL is below 0x20, and must be reported as EmbeddedNull, not NotAscii. + std::expected const built = checked_symbol(spelling); + if (!built) + return std::unexpected { built.error() }; + if (!detail::is_printable_ascii(spelling)) + return std::unexpected { SymbolError::NotAscii }; + return *built; +} + +/// Whether one end of a `Bounds` range is declared: a `bool` that only a `bool` can set. +/// +/// It reads as a `bool` -- in `if`, `!`, `&&`, `||` and when assigned to one -- but no other type converts to it, so +/// a `Bounds` written with positional initialisers whose second value is a number, `{ true, 0, 1, 100, 1 }`, does not +/// compile instead of putting every value one member later. Structural, so a `Unit` holding it stays a template +/// argument. Test it directly, `if (range.lowPresent)`: `range.lowPresent == true` is ambiguous, and it has no +/// `std::format`. +struct BoundsEnd +{ + /// Whether this end of the range is declared. + bool declared = false; + + /// An end that is declared when @p isDeclared is `true`. + constexpr BoundsEnd(bool isDeclared) noexcept: declared { isDeclared } {} + + /// Refuses every type but `bool`: a number, a pointer or an enumerator never stands for a declared end. + template + BoundsEnd(NotBool) = delete; + + /// Whether this end of the range is declared. + [[nodiscard]] constexpr operator bool() const noexcept { return declared; } + + /// Memberwise equality. + [[nodiscard]] constexpr bool operator==(BoundsEnd const&) const noexcept = default; +}; + +/// Optional validity range, in the unit's own scale, as exact rationals. Either end may be declared on its own, both +/// may be, or neither; an undeclared end's fields are ignored. struct Bounds { - /// Whether a range was declared at all -- `false` for a unit with no bounds. - bool present = false; + /// Whether a minimum was declared. `false` for a unit with no lower limit. + BoundsEnd lowPresent = false; + /// Whether a maximum was declared. `false` for a unit with no upper limit. + BoundsEnd highPresent = false; /// Numerator of the declared minimum. std::int64_t lowNumerator = 0; /// Denominator of the declared minimum. @@ -48,7 +103,19 @@ struct Bounds std::int64_t highNumerator, std::int64_t highDenominator) noexcept { - return { true, lowNumerator, lowDenominator, highNumerator, highDenominator }; + return { true, true, lowNumerator, lowDenominator, highNumerator, highDenominator }; +} + +/// Builds a `Bounds` with a minimum only, as a numerator/denominator pair: no value is too large. +[[nodiscard]] constexpr Bounds at_least(std::int64_t lowNumerator, std::int64_t lowDenominator) noexcept +{ + return { true, false, lowNumerator, lowDenominator, 0, 1 }; +} + +/// Builds a `Bounds` with a maximum only, as a numerator/denominator pair: no value is too small. +[[nodiscard]] constexpr Bounds at_most(std::int64_t highNumerator, std::int64_t highDenominator) noexcept +{ + return { false, true, 0, 1, highNumerator, highDenominator }; } /// A unit of measurement. @@ -78,6 +145,10 @@ struct Unit std::int64_t offsetDenominator = 1; /// How the unit is written: `mm`, `°C`, and so on. Symbol symbolText {}; + /// A stable ASCII key for serialising the unit -- a JSON annotation, a database column, a client's choice -- that + /// stays the same when `symbolText` is restyled. Empty when the symbol is its own key; required when the symbol + /// is not printable ASCII (see `RequireAsciiKey`). Never displayed: read it with `view_ascii`. + Symbol asciiText {}; /// The declared display precision -- see `declared_decimals`. std::int32_t decimals = 3; /// The declared validity range, if any -- see `checked_within_bounds`. @@ -87,6 +158,57 @@ struct Unit [[nodiscard]] constexpr bool operator==(Unit const&) const noexcept = default; }; +/// The unit's serialising key: `asciiText` when one is declared, otherwise `symbolText`. Stable across restyling of +/// the display symbol. A unit the library takes as a quantity's, constant's, rounding's, table's or other formula +/// node's unit is printable ASCII here (`RequireAsciiKey`); for any other unit, such as one built at run time, ask +/// `has_ascii_key` first. +[[nodiscard]] constexpr std::string_view view_ascii(Unit const& unitValue) noexcept +{ + std::string_view const declaredKey = view(unitValue.asciiText); + return declaredKey.empty() ? view(unitValue.symbolText) : declaredKey; +} + +/// Deleted: the view would point into a destroyed temporary, as for `view(Symbol&&)`. +std::string_view view_ascii(Unit&&) = delete; + +/// Whether @p unitValue has a printable-ASCII key: its declared `asciiText`, or, when none is declared, its symbol. +[[nodiscard]] constexpr bool has_ascii_key(Unit const& unitValue) noexcept +{ + return detail::is_printable_ascii(view_ascii(unitValue)); +} + +namespace detail +{ + /// @p declared with its display precision replaced by @p places: how a quantity that declares its own places + /// holds its unit. + [[nodiscard]] constexpr Unit with_decimals(Unit declared, DecimalPlaces places) noexcept + { + declared.decimals = places.value; + return declared; + } + + /// Whether two units put values on one scale: the same dimension, factor and offset, compared as declared. + [[nodiscard]] constexpr bool same_scale(Unit const& leftUnit, Unit const& rightUnit) noexcept + { + return leftUnit.dimension == rightUnit.dimension + && leftUnit.magnitudeNumerator == rightUnit.magnitudeNumerator + && leftUnit.magnitudeDenominator == rightUnit.magnitudeDenominator + && leftUnit.offsetNumerator == rightUnit.offsetNumerator + && leftUnit.offsetDenominator == rightUnit.offsetDenominator; + } +} // namespace detail + +/// Whether two units are the same unit: the same scale (dimension, factor, offset, compared as the integer pairs +/// declared, not reduced -- a factor of 2/2000 is not 1/1000 here), the same display symbol and the same key +/// (`view_ascii`). Their declared decimals and bounds may differ -- a quantity read to a tenth of a milliampere and +/// one read to whole milliamperes are both in milliamperes -- which is what `==`, comparing every member, does not +/// answer. Use this, or `view_ascii`, to key a table by unit. +[[nodiscard]] constexpr bool same_unit(Unit const& leftUnit, Unit const& rightUnit) noexcept +{ + return detail::same_scale(leftUnit, rightUnit) && view(leftUnit.symbolText) == view(rightUnit.symbolText) + && view_ascii(leftUnit) == view_ascii(rightUnit); +} + /// A rounding to decimal places, named once and used wherever a method rounds /// the same way: which unit the places are of, how many, and which way to go. /// `constexpr DecimalRounding tenthMpa { unit::Megapascal, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero };` @@ -116,7 +238,8 @@ struct SignificantRounding /// Named units. The `decimals` values are ordinary engineering defaults, not /// requirements from any standard; a caller that needs a different precision -/// states it at the point of use. +/// states it at the point of use, or declares it on the quantity (`Quantity`'s +/// `Places` parameter). /// /// **Symbols are emitted verbatim in plain text and in Markdown.** /// `render.hpp` appends `view(unit.symbolText)` to the number without escaping @@ -153,6 +276,7 @@ namespace unit .magnitudeNumerator = 1, .magnitudeDenominator = 1000, .symbolText = symbol("\xe2\x80\xb0"), + .asciiText = symbol("permille"), .decimals = 1 }; /// One part in a million. No decimals: a figure in parts per million is /// already at the resolution the number carries, and a fraction of one part @@ -203,6 +327,7 @@ namespace unit .magnitudeNumerator = 1, .magnitudeDenominator = 1000000, .symbolText = symbol("\xc2\xb5" "m"), + .asciiText = symbol("um"), .decimals = 0 }; /// One thousand metres. inline constexpr Unit Kilometre { .dimension = dim::Length, @@ -314,6 +439,7 @@ namespace unit .offsetNumerator = 27315, .offsetDenominator = 100, .symbolText = symbol("\xc2\xb0" "C"), + .asciiText = symbol("degC"), .decimals = 1 }; /// The other affine unit. A degree is exactly 5/9 of a kelvin, and zero /// degrees Fahrenheit is exactly 459.67 * 5/9 = 45967/180 kelvin, so no @@ -328,6 +454,7 @@ namespace unit .offsetNumerator = 45967, .offsetDenominator = 180, .symbolText = symbol("\xc2\xb0" "F"), + .asciiText = symbol("degF"), .decimals = 1 }; /// The coherent SI unit of force. One decimal rather than `Pascal`'s @@ -577,6 +704,21 @@ namespace detail /// Always `true` once reached -- the `static_assert` above already failed compilation otherwise. static constexpr bool value = true; }; + + /// Fails to compile when @p U's symbol is not printable ASCII and it declares no ASCII key (`has_ascii_key`). + /// Asserted wherever `RequireNamedScaledScalar` is, so every unit the library takes as a quantity's, constant's, + /// rounding's, table's or other formula node's unit can be serialised by `view_ascii`. Write `::value`, as there. + template + struct RequireAsciiKey + { + static_assert(has_ascii_key(U), + "formula: a unit whose symbol is not ASCII must declare an ASCII key (asciiText), for " + "example .asciiText = formula::symbol(\"ug/L\") for ug/L written with a micro sign; the unit " + "appears in this diagnostic as the template argument of RequireAsciiKey"); + + /// Always `true` once reached -- the `static_assert` above already failed compilation otherwise. + static constexpr bool value = true; + }; } // namespace detail /// The coherent unit of a dimension: magnitude one, offset zero, no symbol -- @@ -657,23 +799,25 @@ namespace detail return detail::or_throw(checked_convert(magnitude, from, to)); } -/// The outcome of checking a value against its unit's declared bounds. +/// The outcome of checking a value against bounds: those its unit declares (`checked_within_bounds`), or limits +/// given at run time (`checked_within`). enum class BoundsCheck : std::uint8_t { - /// Bounds were declared and the value lies within them, inclusive. + /// There were bounds to check, and the value lies within them, inclusive. WithinBounds, - /// Below the declared minimum. + /// Below the minimum, declared by the unit or given at run time. BelowMinimum, - /// Above the declared maximum. + /// Above the maximum, declared by the unit or given at run time. AboveMaximum, - /// The unit declares no bounds, so nothing was checked. Deliberately NOT - /// the same as WithinBounds: a value that was never checked must not be - /// reported as one that was checked and passed. + /// There were no bounds -- the unit declares none, or neither end was + /// given at run time -- so nothing was checked. Deliberately NOT the same + /// as WithinBounds: a value that was never checked must not be reported as + /// one that was checked and passed. NotChecked, - /// There was no value to check. Distinct from NotChecked, which says the - /// unit declares no range: a measurement nobody took and a range nobody - /// declared are different facts, and a report that shows them as one is the - /// collapse NotChecked exists to prevent. + /// There was no value to check. Distinct from NotChecked, which says there + /// was no range: a measurement nobody took and a range nobody declared are + /// different facts, and a report that shows them as one is the collapse + /// NotChecked exists to prevent. NotMeasured, }; @@ -697,35 +841,61 @@ template <> inline constexpr bool formats_by_describe = true; } // namespace detail -/// Checks @p magnitude, expressed in @p unitOfValue, against that unit's bounds. -[[nodiscard]] constexpr std::expected checked_within_bounds(Rational magnitude, - Unit unitOfValue) noexcept +/// Checks @p magnitude against limits held at run time -- a specification row, a catalogue entry -- either of which +/// may be absent. Both ends are inclusive. With no end there is nothing to check (`NotChecked`, never +/// `WithinBounds`); a lower end above the upper one is a malformed pair of limits, refused as `DomainError`. +[[nodiscard]] constexpr std::expected checked_within( + Rational magnitude, std::optional lowEnd, std::optional highEnd) noexcept { - if (!unitOfValue.bounds.present) + if (!lowEnd && !highEnd) return BoundsCheck::NotChecked; - - std::expected const lowBound = - Rational::make(unitOfValue.bounds.lowNumerator, unitOfValue.bounds.lowDenominator); - std::expected const highBound = - Rational::make(unitOfValue.bounds.highNumerator, unitOfValue.bounds.highDenominator); - if (!lowBound) - return std::unexpected { lowBound.error() }; - if (!highBound) - return std::unexpected { highBound.error() }; - - // A unit whose declared minimum exceeds its maximum is a malformed unit, - // not a value to be judged. Reporting BelowMinimum or AboveMaximum here - // would be a wrong answer dressed up as a real one; refuse instead. - if (*lowBound > *highBound) + if (lowEnd && highEnd && *lowEnd > *highEnd) return std::unexpected { ArithmeticError::DomainError }; - - if (magnitude < *lowBound) + if (lowEnd && magnitude < *lowEnd) return BoundsCheck::BelowMinimum; - if (magnitude > *highBound) + if (highEnd && magnitude > *highEnd) return BoundsCheck::AboveMaximum; return BoundsCheck::WithinBounds; } +/// @throws ArithmeticException when the checked form would report an error. +[[nodiscard]] constexpr BoundsCheck within(Rational magnitude, std::optional lowEnd, + std::optional highEnd) +{ + return detail::or_throw(checked_within(magnitude, lowEnd, highEnd)); +} + +/// Checks @p magnitude, expressed in @p unitOfValue, against the ends that unit's bounds declare, as +/// `checked_within`; an end the unit does not declare is not checked. +[[nodiscard]] constexpr std::expected checked_within_bounds(Rational magnitude, + Unit unitOfValue) noexcept +{ + std::optional lowEnd; + std::optional highEnd; + if (unitOfValue.bounds.lowPresent) + { + std::expected const lowBound = + Rational::make(unitOfValue.bounds.lowNumerator, unitOfValue.bounds.lowDenominator); + if (!lowBound) + return std::unexpected { lowBound.error() }; + lowEnd = *lowBound; + } + if (unitOfValue.bounds.highPresent) + { + std::expected const highBound = + Rational::make(unitOfValue.bounds.highNumerator, unitOfValue.bounds.highDenominator); + if (!highBound) + return std::unexpected { highBound.error() }; + highEnd = *highBound; + } + + // A unit whose declared minimum exceeds its maximum is a malformed unit, + // not a value to be judged. Reporting BelowMinimum or AboveMaximum there + // would be a wrong answer dressed up as a real one, so `checked_within` + // refuses the pair instead. + return checked_within(magnitude, lowEnd, highEnd); +} + /// The unit's declared display precision, as the rounding layer's own type. [[nodiscard]] constexpr DecimalPlaces declared_decimals(Unit unitOfValue) noexcept { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 5bb81d5..da658b0 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -32,6 +32,7 @@ add_executable(formula-cpp-tests expression_tests.cpp rational_tests.cpp rational_literal_tests.cpp + decimal_parsing_tests.cpp render_tests.cpp rounding_tests.cpp dimension_tests.cpp @@ -325,7 +326,7 @@ formula_add_negative_test(expression_too_many_named_bases "formula_dimension_has # once for the constant it initialises and once for the call, each naming the # sentinel, while cl and g++ report it once. formula_add_negative_test(base_dimension_name_empty "formula_base_dimension_name_must_not_be_empty") -# A 16-byte name would also overflow symbol(); the base's own rule must be the one that fires. +# A 32-byte name would also overflow symbol(); the base's own rule must be the one that fires. formula_add_negative_test(base_dimension_name_too_long "formula_base_dimension_name_too_long" REJECT "formula_unit_symbol_too_long") formula_add_negative_test(base_dimension_name_not_identifier @@ -353,6 +354,15 @@ formula_add_negative_test(scaled_scalar_unit_without_symbol_constant formula_add_negative_test(scaled_scalar_unit_without_symbol_snap "formula: a dimensionless unit with a scale must have a symbol" EXPECT_COUNT 1 REJECT "key unit does not measure" "breakpoints do not strictly ascend") +formula_add_negative_test(unit_without_ascii_key_var + "formula: a unit whose symbol is not ASCII must declare an ASCII key" EXPECT_COUNT 1) +formula_add_negative_test(unit_without_ascii_key_measured + "formula: a unit whose symbol is not ASCII must declare an ASCII key" EXPECT_COUNT 1) +formula_add_negative_test(unit_without_ascii_key_constant + "formula: a unit whose symbol is not ASCII must declare an ASCII key" EXPECT_COUNT 1) +formula_add_negative_test(unit_without_ascii_key_snap + "formula: a unit whose symbol is not ASCII must declare an ASCII key" EXPECT_COUNT 1 + REJECT "key unit does not measure" "breakpoints do not strictly ascend") # A conversion between measured quantities of different dimensions is refused where # it is written, with or without a value, and once. @@ -363,10 +373,32 @@ formula_add_negative_test(measured_convert_dimension_mismatch_absent formula_add_negative_test(measured_convert_currency_mismatch "formula: these two quantities measure different dimensions, so no conversion between them exists" EXPECT_COUNT 1) +# A checked_transform or checked_combine callback reports failure through +# std::expected; one returning a bare Rational belongs to transform or combine. +formula_add_negative_test(checked_transform_plain_callback + "formula: a checked_transform callback must return std::expected" EXPECT_COUNT 1) +formula_add_negative_test(checked_combine_plain_callback + "formula: a checked_combine callback must return std::expected" EXPECT_COUNT 1) +# A callback that cannot be called with the measured values at all is refused +# in the library's own words, not the standard library's. +formula_add_negative_test(checked_transform_uncallable_callback + "formula: a checked_transform callback must be callable with a Rational" EXPECT_COUNT 1) +formula_add_negative_test(checked_combine_uncallable_callback + "formula: a checked_combine callback must be callable with two Rationals" EXPECT_COUNT 1) + # Same NAME-not-message case as the exponent sentinels above, for the same # reason: symbol() rejects an over-long symbol by calling a deliberately # non-`constexpr` sentinel rather than truncating it. formula_add_negative_test(unit_symbol_too_long "formula_unit_symbol_too_long") +# symbol() is consteval, so a spelling that only exists at run time is refused +# where it is passed. cl says "call to immediate function is not a constant +# expression", clang and g++ "call to consteval function ... is not a constant +# expression"; the expected text is the part they share. +formula_add_negative_test(unit_symbol_from_runtime_text "is not a constant expression") +# Only a bool sets a Bounds end's flag, so a positional Bounds whose second value is a number is refused. g++ says +# "use of deleted function", clang "invokes a deleted function", cl "attempting to reference a deleted function"; +# the expected text is the part they share. +formula_add_negative_test(bounds_positional_initialiser "deleted function") formula_add_negative_test(fixed_string_not_terminated "formula_fixed_string_must_be_null_terminated") diff --git a/test/checked_int_tests.cpp b/test/checked_int_tests.cpp index c3d9fb9..d85c41a 100644 --- a/test/checked_int_tests.cpp +++ b/test/checked_int_tests.cpp @@ -289,5 +289,9 @@ TEST_CASE("the helpers that read an integer of either width", "[checked-int]") STATIC_REQUIRE(formula::detail::decimal_digits(Int128 { 0 }) == 1); STATIC_REQUIRE(formula::detail::mul_pow10(Int128 { 3 }, 18) == std::optional { Int128 { 3'000'000'000'000'000'000LL } }); - STATIC_REQUIRE(formula::detail::mul_pow10(Int128 { 3 }, 19) == std::nullopt); + STATIC_REQUIRE(formula::detail::mul_pow10(Int128 { 3 }, 19) + == std::optional { Int128 { 3'000'000'000'000'000'000LL } * Int128 { 10 } }); + STATIC_REQUIRE(formula::detail::mul_pow10(Int128 { 1 }, 38) == formula::detail::pow10_wide(38)); + STATIC_REQUIRE(formula::detail::mul_pow10(Int128 { 1 }, 39) == std::nullopt); + STATIC_REQUIRE(formula::detail::pow10_wide(-1) == std::nullopt); } diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index c6ccccf..72ccd6d 100644 --- a/test/consumer_globals_run_tests.cpp +++ b/test/consumer_globals_run_tests.cpp @@ -13,7 +13,7 @@ TEST_CASE("a consumer's ordinary globals do not break a build that includes every header", "[hygiene]") { ConsumerGlobalsProbe const probe = probe_consumer_globals(); - REQUIRE(probe.checks.size() == 119); + REQUIRE(probe.checks.size() == 120); for (std::size_t index = 0; index < probe.checks.size(); ++index) { INFO("check " << index); diff --git a/test/consumer_globals_tests.cpp b/test/consumer_globals_tests.cpp index 36089a2..cc4bc15 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -32,7 +32,8 @@ // with a sink of its own; `apply` with every overlay operation; `Outcome`'s // factories; `checked_convert_to`, `checked_within_bounds`, // `checked_round_to_declared`, their throwing twins `convert_to`, -// `within_bounds` and `round_to_declared`, `transform` and `combine`; `entered`, +// `within_bounds` and `round_to_declared`, `transform` and `combine`, +// `checked_transform` and `checked_combine`; `entered`, // `Environment::get` and `source_of`; // `measured_series`, `entered` of a series, `Environment::get_series` and // `checked_evaluate_series` of a series variable, derived and entered; @@ -610,6 +611,14 @@ ConsumerGlobalsProbe probe_consumer_globals() probe.checks.push_back(convertedEdge == *inMetres && roundedEdge == *declared && formula::within_bounds(edge) == *withinBounds); probe.checks.push_back(doubled.value() == formula::Rational { 300 } && summed.value() == formula::Rational { 300 }); + auto const checkedDoubled = formula::checked_transform( + edge, [](formula::Rational measured) noexcept { return formula::checked_mul(measured, formula::Rational { 2 }); }); + auto const checkedSummed = formula::checked_combine( + edge, edge, [](formula::Rational augend, formula::Rational addend) noexcept { + return formula::checked_add(augend, addend); + }); + probe.checks.push_back(checkedDoubled.has_value() && checkedDoubled->value() == formula::Rational { 300 } + && checkedSummed.has_value() && checkedSummed->value() == formula::Rational { 300 }); // A series: built, entered, read from an environment and evaluated both // ways, derived and entered. diff --git a/test/decimal_parsing_tests.cpp b/test/decimal_parsing_tests.cpp new file mode 100644 index 0000000..e1ec68f --- /dev/null +++ b/test/decimal_parsing_tests.cpp @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include +#include +#include +#include +#include + +using formula::ArithmeticError; +using formula::ParsedDecimal; +using formula::Rational; + +namespace +{ + constexpr bool parses_as(std::string_view spelling, Rational expectedValue, std::int32_t expectedPlaces) + { + std::expected const parsed = formula::parse_decimal_text(spelling); + return parsed.has_value() && parsed->value == expectedValue && parsed->places == expectedPlaces; + } + + constexpr bool refused_as(std::string_view spelling, ArithmeticError expectedError) + { + std::expected const parsed = formula::parse_decimal_text(spelling); + return !parsed.has_value() && parsed.error() == expectedError; + } +} // namespace + +TEST_CASE("parse_decimal_text: the value and the places as typed", "[rational][text]") +{ + STATIC_REQUIRE(parses_as("2.400", Rational { 12, 5 }, 3)); + STATIC_REQUIRE(parses_as("2.4", Rational { 12, 5 }, 1)); + STATIC_REQUIRE(parses_as("7", Rational { 7 }, 0)); + STATIC_REQUIRE(parses_as("1.5e3", Rational { 1500 }, 0)); + STATIC_REQUIRE(parses_as("15e-1", Rational { 3, 2 }, 1)); + STATIC_REQUIRE(parses_as("1e-3", Rational { 1, 1000 }, 3)); + STATIC_REQUIRE(parses_as("2.5E+2", Rational { 250 }, 0)); + STATIC_REQUIRE(parses_as("-27.3", Rational { -273, 10 }, 1)); + STATIC_REQUIRE(parses_as("+0.0", Rational {}, 1)); + STATIC_REQUIRE(parses_as("-0", Rational {}, 0)); + STATIC_REQUIRE(parses_as("-0.0", Rational {}, 1)); + STATIC_REQUIRE(parses_as(".5", Rational { 1, 2 }, 1)); + STATIC_REQUIRE(parses_as("5.", Rational { 5 }, 0)); + STATIC_REQUIRE(parses_as("007", Rational { 7 }, 0)); + STATIC_REQUIRE(parses_as("0e99999", Rational {}, 0)); + STATIC_REQUIRE(parses_as("0e-12345", Rational {}, 12345)); // a zero's places count every exponent digit + STATIC_REQUIRE(parses_as("0.000e5", Rational {}, 0)); +} + +TEST_CASE("parse_decimal_text: text that is not a decimal is DomainError", "[rational][text]") +{ + for (std::string_view const spelling: { "", " 1", "1 ", "1,5", "1'000", "1_000", "1.2.3", "0x1F", "inf", "nan", + "1e", "1e+", "e5", "+", "-", ".", "+-1", "1e2.5", "--1" }) + { + CAPTURE(spelling); + REQUIRE(refused_as(spelling, ArithmeticError::DomainError)); + } +} + +TEST_CASE("parse_decimal_text: a value no Rational holds is Overflow", "[rational][text]") +{ + STATIC_REQUIRE(parses_as("170141183460469231731687303715884105727", + Rational { std::numeric_limits::max() }, 0)); + STATIC_REQUIRE(refused_as("170141183460469231731687303715884105728", ArithmeticError::Overflow)); + STATIC_REQUIRE(parses_as("1e38", *Rational::from_decimal(1, 38), 0)); + STATIC_REQUIRE(refused_as("1e39", ArithmeticError::Overflow)); + STATIC_REQUIRE(refused_as("1e-39", ArithmeticError::Overflow)); + STATIC_REQUIRE(refused_as("1e1001", ArithmeticError::Overflow)); + STATIC_REQUIRE(refused_as("0e-99999999999", ArithmeticError::Overflow)); // places no std::int32_t states + // A thousand fractional zeros before a digit: refused, not a wrong value and not a hang. + std::string const deepFraction = "0." + std::string(1000, '0') + "1"; + REQUIRE(refused_as(deepFraction, ArithmeticError::Overflow)); + // Trailing fractional zeros cost nothing, however many. + std::string const manyTrailingZeros = "1." + std::string(1000, '0'); + std::expected const trailing = formula::parse_decimal_text(manyTrailingZeros); + REQUIRE(trailing.has_value()); + REQUIRE(trailing->value == Rational { 1 }); + REQUIRE(trailing->places == 1000); +} + +TEST_CASE("Rational::from_decimal_text: the value parse_decimal_text reads", "[rational][text]") +{ + STATIC_REQUIRE(*Rational::from_decimal_text("2.400") == Rational { 12, 5 }); + STATIC_REQUIRE(Rational::from_decimal_text("2,4").error() == ArithmeticError::DomainError); + std::string const fromCsv = "0.0213"; + std::expected const atRunTime = Rational::from_decimal_text(fromCsv); + REQUIRE(atRunTime.has_value()); + REQUIRE(*atRunTime == Rational { 213, 10'000 }); +} diff --git a/test/dimension_tests.cpp b/test/dimension_tests.cpp index 765b460..4e80bdb 100644 --- a/test/dimension_tests.cpp +++ b/test/dimension_tests.cpp @@ -5,6 +5,7 @@ #include #include +#include #include using formula::Exponent; @@ -231,8 +232,11 @@ static_assert((FourBases / JPY * Token).namedBases[2].name == symbol("Token")); static_assert(formula::detail::merged_dimension(FourBases, Token / Voucher, false).fits, "five names go in, one cancels, four come out"); -// The longest name that fits, and digits after the first letter. -static_assert(formula::base_dimension("AcmeLoyaltyUnit").namedBases[0].name == symbol("AcmeLoyaltyUnit")); +// The longest name that fits -- 31 bytes, SymbolCapacity less the +// terminator -- and digits after the first letter. +static_assert(std::string_view { "AcmeLoyaltyProgrammeBonusPoints" }.size() == formula::SymbolCapacity - 1); +static_assert(formula::base_dimension("AcmeLoyaltyProgrammeBonusPoints").namedBases[0].name + == symbol("AcmeLoyaltyProgrammeBonusPoints")); static_assert(formula::base_dimension("Credit2").namedBases[0].name == symbol("Credit2")); // g++ 13.3 and 14.2 miscompile what copy_then_overwrite_last_slot() does when diff --git a/test/format_tests.cpp b/test/format_tests.cpp index d8a9d5f..c25b3f6 100644 --- a/test/format_tests.cpp +++ b/test/format_tests.cpp @@ -397,6 +397,8 @@ TEST_CASE("a described enumeration is its words, aligned like a string", "[forma CHECK(std::format("[{:<12}]", formula::ConstraintOutcomeKind::Violated) == "[violated ]"); CHECK(std::format("{}", formula::ValueSource::ManuallyEntered) == "manually entered"); CHECK(std::format("{}", formula::RetryEnd::Accepted) == formula::describe(formula::RetryEnd::Accepted)); + CHECK(std::format("{}", formula::SymbolError::TooLong) + == "the symbol does not fit SymbolCapacity bytes, terminator included"); } TEST_CASE("every enumeration with a describe() is formattable, and no other", "[format]") @@ -413,7 +415,38 @@ TEST_CASE("every enumeration with a describe() is formattable, and no other", "[ STATIC_REQUIRE(std::formattable); STATIC_REQUIRE(std::formattable); STATIC_REQUIRE(std::formattable); + STATIC_REQUIRE(std::formattable); // An enumeration with no describe() is not written: the formatter is not // a blanket one for every enumeration. STATIC_REQUIRE(!std::formattable); } + +namespace +{ + +// Invented: milliamperes, declared to whole milliamperes, and a quantity in +// them that declares one place of its own. +inline constexpr formula::Unit Milliampere { .dimension = formula::dim::Current, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, + .symbolText = formula::symbol("mA"), + .decimals = 0 }; +struct FineCurrent: formula::Quantity +{ +}; +struct CoarseCurrent: formula::Quantity +{ +}; + +} // namespace + +TEST_CASE("a Measured formats to the places its quantity declares", "[format][decimals]") +{ + // 37/3 mA is 12.333... mA: rounded, to one place and to none. + CHECK(std::format("{:~HalfEven}", Measured { Rational { 37, 3 } }) == "\xe2\x89\x88" "12.3 mA"); + CHECK(std::format("{:~HalfEven}", Measured { Rational { 37, 3 } }) == "\xe2\x89\x88" "12 mA"); + // An exact decimal is never cut short, whatever the places. + CHECK(std::format("{:~HalfEven}", Measured { Rational { 1234, 100 } }) == "12.34 mA"); + CHECK(std::format("{:~HalfEven}", Measured { Rational { 1234, 100 } }) == "12.34 mA"); +} diff --git a/test/measured_tests.cpp b/test/measured_tests.cpp index 390f2b5..3cb91aa 100644 --- a/test/measured_tests.cpp +++ b/test/measured_tests.cpp @@ -4,7 +4,9 @@ #include +#include #include +#include #include namespace unit = formula::unit; @@ -267,6 +269,17 @@ TEST_CASE("an unmeasured value is not judged against bounds", "[measured]") CHECK(*present == formula::BoundsCheck::NotChecked); } +TEST_CASE("checked_within on a measurement: absent is NotMeasured", "[measured][bounds]") +{ + constexpr formula::Measured absentReading {}; + STATIC_REQUIRE(*formula::checked_within(absentReading, formula::Rational { 0 }, std::nullopt) + == formula::BoundsCheck::NotMeasured); + constexpr formula::Measured present { formula::Rational { 3 } }; + STATIC_REQUIRE(*formula::checked_within(present, formula::Rational { 0 }, std::nullopt) + == formula::BoundsCheck::WithinBounds); + REQUIRE(formula::within(present, std::nullopt, formula::Rational { 2 }) == formula::BoundsCheck::AboveMaximum); +} + TEST_CASE("rounding to declared precision leaves an absent value absent", "[measured]") { // Millimetre declares one decimal place. @@ -517,3 +530,117 @@ TEST_CASE("the throwing twins throw the error their checked form returns", "[mea CHECK_THROWS_AS(formula::round_to_declared(unroundable, formula::RoundingMode::HalfEven), formula::ArithmeticException); } + +// ---- a quantity's own decimal places ---- + +namespace +{ + +// Invented: milliamperes, declared to whole milliamperes, and a quantity in +// them that declares one place of its own. +inline constexpr formula::Unit Milliampere { .dimension = formula::dim::Current, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, + .symbolText = formula::symbol("mA"), + .decimals = 0 }; +struct FineCurrent: formula::Quantity +{ +}; +struct CoarseCurrent: formula::Quantity +{ +}; + +} // namespace + +TEST_CASE("rounding to declared precision uses the places the quantity declares", "[measured][decimals]") +{ + auto const fine = + formula::checked_round_to_declared(Measured { Rational { 1234, 100 } }, + formula::RoundingMode::HalfAwayFromZero); + REQUIRE(fine.has_value()); + REQUIRE(fine->has_value()); + CHECK(fine->value() == Rational { 123, 10 }); + + auto const coarse = + formula::checked_round_to_declared(Measured { Rational { 1234, 100 } }, + formula::RoundingMode::HalfAwayFromZero); + REQUIRE(coarse.has_value()); + REQUIRE(coarse->has_value()); + CHECK(coarse->value() == Rational { 12 }); +} + +TEST_CASE("checked_transform: present, absent, and an error from the callback", "[measured][checked]") +{ + constexpr auto timesTen = [](Rational reading) noexcept { return formula::checked_mul(reading, Rational { 10 }); }; + constexpr std::expected, ArithmeticError> diluted = + formula::checked_transform(Measured { Rational { 3, 2 } }, timesTen); + STATIC_REQUIRE(diluted.has_value()); + STATIC_REQUIRE(diluted->value() == Rational { 15 }); + + int calls = 0; + auto const counting = [&calls](Rational reading) { + ++calls; + return std::expected { reading }; + }; + std::expected, ArithmeticError> const absent = + formula::checked_transform(Measured {}, counting); + REQUIRE(absent.has_value()); + REQUIRE(absent->is_absent()); + REQUIRE(calls == 0); + + constexpr auto overflowing = [](Rational) noexcept { + return std::expected { std::unexpected { ArithmeticError::Overflow } }; + }; + constexpr std::expected, ArithmeticError> refused = + formula::checked_transform(Measured { Rational { 1 } }, overflowing); + STATIC_REQUIRE(refused.error() == ArithmeticError::Overflow); + + STATIC_REQUIRE(noexcept(formula::checked_transform(Measured {}, timesTen))); + STATIC_REQUIRE(!noexcept(formula::checked_transform(Measured {}, counting))); +} + +TEST_CASE("checked_combine: absent if either is absent; an error propagates", "[measured][checked]") +{ + constexpr auto multiply = [](Rational lhsReading, Rational rhsReading) noexcept { + return formula::checked_mul(lhsReading, rhsReading); + }; + constexpr std::expected, ArithmeticError> product = formula::checked_combine( + Measured { Rational { 2 } }, Measured { Rational { 3 } }, multiply); + STATIC_REQUIRE(product.has_value()); + STATIC_REQUIRE(product->value() == Rational { 6 }); + constexpr std::expected, ArithmeticError> oneAbsent = formula::checked_combine( + Measured {}, Measured { Rational { 3 } }, multiply); + STATIC_REQUIRE(oneAbsent.has_value()); + STATIC_REQUIRE(oneAbsent->is_absent()); + constexpr std::expected, ArithmeticError> otherAbsent = formula::checked_combine( + Measured { Rational { 2 } }, Measured {}, multiply); + STATIC_REQUIRE(otherAbsent.has_value()); + STATIC_REQUIRE(otherAbsent->is_absent()); + + int calls = 0; + auto const counting = [&calls](Rational lhsReading, Rational rhsReading) { + ++calls; + return formula::checked_mul(lhsReading, rhsReading); + }; + std::expected, ArithmeticError> const leftAbsent = formula::checked_combine( + Measured {}, Measured { Rational { 3 } }, counting); + std::expected, ArithmeticError> const rightAbsent = formula::checked_combine( + Measured { Rational { 2 } }, Measured {}, counting); + REQUIRE(leftAbsent.has_value()); + REQUIRE(leftAbsent->is_absent()); + REQUIRE(rightAbsent.has_value()); + REQUIRE(rightAbsent->is_absent()); + REQUIRE(calls == 0); + + constexpr auto dividing = [](Rational lhsReading, Rational rhsReading) noexcept { + return formula::checked_div(lhsReading, rhsReading); + }; + constexpr std::expected, ArithmeticError> byZero = formula::checked_combine( + Measured { Rational { 2 } }, Measured { Rational {} }, dividing); + STATIC_REQUIRE(byZero.error() == ArithmeticError::DivisionByZero); + STATIC_REQUIRE( + noexcept(formula::checked_combine(Measured {}, Measured {}, multiply))); + STATIC_REQUIRE( + !noexcept(formula::checked_combine(Measured {}, Measured {}, counting))); +} diff --git a/test/negative/base_dimension_name_too_long.cpp b/test/negative/base_dimension_name_too_long.cpp index 6c55428..f343b82 100644 --- a/test/negative/base_dimension_name_too_long.cpp +++ b/test/negative/base_dimension_name_too_long.cpp @@ -2,12 +2,12 @@ // EXPECT: formula_base_dimension_name_too_long // REJECT: formula_unit_symbol_too_long // -// A base name of 16 bytes does not fit a Symbol with its terminator. The -// base's own rule refuses it, before symbol() would refuse it in its own -// words. This must not compile. +// A base name of 32 bytes -- one more than the 31 usable -- does not fit a +// Symbol with its terminator. The base's own rule refuses it, before symbol() +// would refuse it in its own words. This must not compile. #include -inline constexpr formula::Dimension Refused = formula::base_dimension("AcmeLoyaltyUnits"); +inline constexpr formula::Dimension Refused = formula::base_dimension("AcmeLoyaltyProgrammePointsLedger"); int main() { diff --git a/test/negative/bounds_positional_initialiser.cpp b/test/negative/bounds_positional_initialiser.cpp new file mode 100644 index 0000000..fcc28c7 --- /dev/null +++ b/test/negative/bounds_positional_initialiser.cpp @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +// A Bounds written with positional initialisers in the order it had before each end got its own flag: once a range of +// 0 to 100, it would now read as a minimum of 1/100 and no maximum. Only a bool sets a BoundsEnd, so the 0 meant for +// the low numerator is refused where it lands on highPresent. This must not compile. +#include + +int main() +{ + formula::Bounds const percentRange { true, 0, 1, 100, 1 }; + return percentRange.lowPresent ? 0 : 1; +} diff --git a/test/negative/checked_combine_plain_callback.cpp b/test/negative/checked_combine_plain_callback.cpp new file mode 100644 index 0000000..b8e1fb3 --- /dev/null +++ b/test/negative/checked_combine_plain_callback.cpp @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a checked_combine callback must return std::expected +// +// checked_combine's callback reports failure through std::expected; one returning a bare Rational belongs to +// combine. This must not compile. +#include + +struct Reading: formula::Quantity +{ +}; + +int main() +{ + auto const plain = [](formula::Rational augend, formula::Rational addend) { return augend + addend; }; + auto const refused = formula::checked_combine(formula::Measured { formula::Rational { 1 } }, + formula::Measured { formula::Rational { 2 } }, plain); + return refused.has_value() ? 0 : 1; +} diff --git a/test/negative/checked_combine_uncallable_callback.cpp b/test/negative/checked_combine_uncallable_callback.cpp new file mode 100644 index 0000000..07ff0dc --- /dev/null +++ b/test/negative/checked_combine_uncallable_callback.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a checked_combine callback must be callable with two Rationals +// +// A checked_combine callback that cannot be called with two Rationals at all is refused in the library's own words, +// not with the standard library's complaint about a missing invoke_result type. This must not compile. +#include + +#include + +struct Reading: formula::Quantity +{ +}; + +int main() +{ + auto const uncallable = [](std::string_view spelled, formula::Rational addend) { + return formula::checked_add(formula::Rational { spelled.size() }, addend); + }; + auto const refused = formula::checked_combine(formula::Measured { formula::Rational { 1 } }, + formula::Measured { formula::Rational { 2 } }, + uncallable); + return refused.has_value() ? 0 : 1; +} diff --git a/test/negative/checked_transform_plain_callback.cpp b/test/negative/checked_transform_plain_callback.cpp new file mode 100644 index 0000000..c5f2920 --- /dev/null +++ b/test/negative/checked_transform_plain_callback.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a checked_transform callback must return std::expected +// +// checked_transform's callback reports failure through std::expected; one returning a bare Rational belongs to +// transform. This must not compile. +#include + +struct Reading: formula::Quantity +{ +}; + +int main() +{ + auto const plain = [](formula::Rational gauged) { return gauged; }; + auto const refused = formula::checked_transform(formula::Measured { formula::Rational { 1 } }, plain); + return refused.has_value() ? 0 : 1; +} diff --git a/test/negative/checked_transform_uncallable_callback.cpp b/test/negative/checked_transform_uncallable_callback.cpp new file mode 100644 index 0000000..3743085 --- /dev/null +++ b/test/negative/checked_transform_uncallable_callback.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a checked_transform callback must be callable with a Rational +// +// A checked_transform callback that cannot be called with a Rational at all is refused in the library's own words, +// not with the standard library's complaint about a missing invoke_result type. This must not compile. +#include + +#include + +struct Reading: formula::Quantity +{ +}; + +int main() +{ + auto const uncallable = [](std::string_view spelled) { + return std::expected { formula::Rational { spelled.size() } }; + }; + auto const refused = + formula::checked_transform(formula::Measured { formula::Rational { 1 } }, uncallable); + return refused.has_value() ? 0 : 1; +} diff --git a/test/negative/rational_literal_exponent_out_of_range.cpp b/test/negative/rational_literal_exponent_out_of_range.cpp index 9a0c98f..f854cde 100644 --- a/test/negative/rational_literal_exponent_out_of_range.cpp +++ b/test/negative/rational_literal_exponent_out_of_range.cpp @@ -1,11 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 -// 1e19 is an integer no Rational holds, so it is refused however it is spelled. +// 10^39 is an integer no Rational holds (Int128 stops below 1.8 x 10^38), so it is refused however it is spelled. // This must not compile. #include using namespace formula::literals; -constexpr formula::Rational refused = 1e19_r; +constexpr formula::Rational refused = 1e39_r; int main() { diff --git a/test/negative/rational_literal_out_of_range.cpp b/test/negative/rational_literal_out_of_range.cpp index 2651ff4..cb1911f 100644 --- a/test/negative/rational_literal_out_of_range.cpp +++ b/test/negative/rational_literal_out_of_range.cpp @@ -1,11 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 -// One more than the largest integer a Rational holds. +// 2^127, one more than the largest integer a Rational holds (Int128's maximum). // This must not compile. #include using namespace formula::literals; -constexpr formula::Rational refused = 9'223'372'036'854'775'808_r; +constexpr formula::Rational refused = 170'141'183'460'469'231'731'687'303'715'884'105'728_r; int main() { diff --git a/test/negative/rational_literal_too_many_places.cpp b/test/negative/rational_literal_too_many_places.cpp index 4d92214..14bde9b 100644 --- a/test/negative/rational_literal_too_many_places.cpp +++ b/test/negative/rational_literal_too_many_places.cpp @@ -1,11 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 -// A denominator of 10^19 is above the range of the integers a Rational holds. +// A denominator of 10^39 is above Int128's range, the integers a Rational holds. // This must not compile. #include using namespace formula::literals; -constexpr formula::Rational refused = 0.0000000000000000001_r; +constexpr formula::Rational refused = 0.000'000'000'000'000'000'000'000'000'000'000'000'001_r; int main() { diff --git a/test/negative/unit_symbol_from_runtime_text.cpp b/test/negative/unit_symbol_from_runtime_text.cpp new file mode 100644 index 0000000..d632a69 --- /dev/null +++ b/test/negative/unit_symbol_from_runtime_text.cpp @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +// symbol() is consteval: text that only exists at run time cannot reach it, so a symbol too long for +// SymbolCapacity can never abort a running program. Run-time text goes through checked_symbol. This must not compile. +#include + +int main(int argumentCount, char** arguments) +{ + formula::Symbol const fromRunTime = formula::symbol(arguments[argumentCount - 1]); + return fromRunTime.characters[0]; +} diff --git a/test/negative/unit_symbol_too_long.cpp b/test/negative/unit_symbol_too_long.cpp index b7cdbce..497c4ab 100644 --- a/test/negative/unit_symbol_too_long.cpp +++ b/test/negative/unit_symbol_too_long.cpp @@ -1,12 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 -// A symbol that does not fit SymbolCapacity (16 bytes, including the +// A symbol that does not fit SymbolCapacity (32 bytes, including the // terminator) must be rejected, not truncated: truncating could merge two // distinct units into the same type, and could split a multi-byte UTF-8 // character in half. This must not compile. #include -// 16 bytes -- one more than the 15 usable characters -- so this does not fit. -constexpr formula::Symbol bad = formula::symbol("0123456789abcdef"); +// 32 bytes -- one more than the 31 usable characters -- so this does not fit. +constexpr formula::Symbol bad = formula::symbol("0123456789abcdef0123456789abcdef"); int main() { diff --git a/test/negative/unit_without_ascii_key_constant.cpp b/test/negative/unit_without_ascii_key_constant.cpp new file mode 100644 index 0000000..393a1e4 --- /dev/null +++ b/test/negative/unit_without_ascii_key_constant.cpp @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a unit whose symbol is not ASCII must declare an ASCII key +// +// A coefficient stated in micrograms per litre, written with a micro sign and +// no ASCII key: it must not compile, and is refused where it is written, once. +#include + +inline constexpr formula::Unit MicrogramPerLitre { .dimension = formula::dim::Mass / formula::dim::Volume, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1'000'000, + .symbolText = formula::symbol("\xc2\xb5g/L") }; + +inline constexpr auto limit = formula::constant(formula::Rational { 50 }); + +int main() +{ + return limit.number == formula::Rational { 50 } ? 0 : 1; +} diff --git a/test/negative/unit_without_ascii_key_measured.cpp b/test/negative/unit_without_ascii_key_measured.cpp new file mode 100644 index 0000000..1f84aca --- /dev/null +++ b/test/negative/unit_without_ascii_key_measured.cpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a unit whose symbol is not ASCII must declare an ASCII key +// +// A measurement of a quantity declared in micrograms per litre, written with a +// micro sign and no ASCII key, named through Measured alone and never read by +// a formula: it must not compile, and is refused where the quantity's +// description is asked for, once. +#include + +inline constexpr formula::Unit MicrogramPerLitre { .dimension = formula::dim::Mass / formula::dim::Volume, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1'000'000, + .symbolText = formula::symbol("\xc2\xb5g/L") }; + +struct Concentration: formula::Quantity +{ +}; + +int main() +{ + formula::Measured const reading { formula::Rational { 50 } }; + (void) reading; + return 0; +} diff --git a/test/negative/unit_without_ascii_key_snap.cpp b/test/negative/unit_without_ascii_key_snap.cpp new file mode 100644 index 0000000..435dbaf --- /dev/null +++ b/test/negative/unit_without_ascii_key_snap.cpp @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a unit whose symbol is not ASCII must declare an ASCII key +// REJECT: key unit does not measure +// REJECT: breakpoints do not strictly ascend +// +// A snap keyed in micrograms per litre, written with a micro sign and no ASCII +// key: it must not compile. Refused where the snap is written, once, and by +// nothing else: the key measures what the operand does, and the permitted set +// ascends. +#include + +inline constexpr formula::Unit MicrogramPerLitre { .dimension = formula::dim::Mass / formula::dim::Volume, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1'000'000, + .symbolText = formula::symbol("\xc2\xb5g/L") }; + +struct Concentration: formula::Quantity +{ +}; + +inline constexpr formula::BreakpointTable<2> permitted { formula::breakpoint(25), formula::breakpoint(50) }; + +inline constexpr auto snap = + formula::snapped(formula::var); + +int main() +{ + return snap.tie == formula::SnapTie::TowardLower ? 0 : 1; +} diff --git a/test/negative/unit_without_ascii_key_var.cpp b/test/negative/unit_without_ascii_key_var.cpp new file mode 100644 index 0000000..1c2fa3d --- /dev/null +++ b/test/negative/unit_without_ascii_key_var.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +// EXPECT: formula: a unit whose symbol is not ASCII must declare an ASCII key +// +// A quantity declared in micrograms per litre, written with a micro sign and no +// ASCII key, so nothing could serialise it by a stable ASCII name: it must not +// compile, and is refused where the quantity is read, once. +#include + +inline constexpr formula::Unit MicrogramPerLitre { .dimension = formula::dim::Mass / formula::dim::Volume, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1'000'000, + .symbolText = formula::symbol("\xc2\xb5g/L") }; + +struct Concentration: formula::Quantity +{ +}; + +inline constexpr auto concentration = formula::var; + +int main() +{ + return concentration.dimension == formula::dim::Mass / formula::dim::Volume ? 0 : 1; +} diff --git a/test/number_text_tests.cpp b/test/number_text_tests.cpp index c55396f..79d0641 100644 --- a/test/number_text_tests.cpp +++ b/test/number_text_tests.cpp @@ -48,12 +48,13 @@ struct Share: formula::Quantity { }; -/// 18 declared decimals and a symbol of 16 bytes with no terminator -- as +/// 18 declared decimals and a symbol of 32 bytes with no terminator -- as /// many as a `Symbol` holds and `view()` reads -- so that a value in it is /// the longest text this library spells. inline constexpr formula::Unit Widest { .dimension = dim::Scalar, - .symbolText = formula::Symbol { { 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p' } }, + .symbolText = formula::Symbol { { 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', + 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5' } }, .decimals = 18 }; struct WidestReading: formula::Quantity @@ -592,19 +593,63 @@ TEST_CASE("a measured value in a dimensioned unit with no symbol is shown in the TEST_CASE("the longest text this library spells fits its buffer", "[number_text]") { // A sign, the 39 digits of 2^127, a slash, the 39 digits of 2^127 - 1, a - // space and a 16-byte symbol: 97 bytes, the longest text the buffer is + // space and a 32-byte symbol: 113 bytes, the longest text the buffer is // sized for. NumberText const widest = formula::number_text(Measured { Rational { IntMin, IntMax } }, NumberStyle::fraction()); CHECK(widest.view() - == "-170141183460469231731687303715884105728/170141183460469231731687303715884105727 abcdefghijklmnop"); + == "-170141183460469231731687303715884105728/170141183460469231731687303715884105727 " + "abcdefghijklmnopqrstuvwxyz012345"); CHECK(widest.view().size() == formula::detail::LongestNumberText); // The longest marked decimal -- the marker, a sign, 38 whole digits, a - // point, 18 places, a space and the symbol -- is 78 bytes, shorter. + // point, 18 places, a space and the symbol -- is 94 bytes, shorter. NumberText const widestDecimal = formula::number_text(Measured { Rational { IntMin, 3 } }, NumberStyle::approximate_decimal(RoundingMode::HalfEven)); CHECK(widestDecimal.view() - == "\xe2\x89\x88" "-56713727820156410577229101238628035242.666666666666666667 abcdefghijklmnop"); - CHECK(widestDecimal.view().size() == 78); + == "\xe2\x89\x88" "-56713727820156410577229101238628035242.666666666666666667 " + "abcdefghijklmnopqrstuvwxyz012345"); + CHECK(widestDecimal.view().size() == 94); +} + +// ---- a quantity's own decimal places ---- + +namespace +{ + +// Invented: milliamperes, declared to whole milliamperes, and a quantity in +// them that declares one place of its own. +inline constexpr formula::Unit Milliampere { .dimension = dim::Current, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, + .symbolText = formula::symbol("mA"), + .decimals = 0 }; +struct FineCurrent: formula::Quantity +{ +}; +struct CoarseCurrent: formula::Quantity +{ +}; + +} // namespace + +TEST_CASE("a measured value is written to the places its quantity declares", "[number_text][decimals]") +{ + constexpr NumberStyle paddedExact = NumberStyle::exact_decimal(DecimalPadding::Padded); + constexpr NumberStyle evenApproximation = NumberStyle::approximate_decimal(RoundingMode::HalfEven); + + // 37/3 mA is 12.333... mA: rounded, to one place and to none. + STATIC_REQUIRE(formula::number_text(Measured { Rational { 37, 3 } }, evenApproximation) + == "\xe2\x89\x88" "12.3 mA"); + STATIC_REQUIRE(formula::number_text(Measured { Rational { 37, 3 } }, evenApproximation) + == "\xe2\x89\x88" "12 mA"); + // An exact decimal is never cut short, whatever the places. + STATIC_REQUIRE(formula::number_text(Measured { Rational { 1234, 100 } }, evenApproximation) + == "12.34 mA"); + STATIC_REQUIRE(formula::number_text(Measured { Rational { 1234, 100 } }, evenApproximation) + == "12.34 mA"); + // Padded to the places each declares. + STATIC_REQUIRE(formula::number_text(Measured { Rational { 12 } }, paddedExact) == "12.0 mA"); + STATIC_REQUIRE(formula::number_text(Measured { Rational { 12 } }, paddedExact) == "12 mA"); } diff --git a/test/opaque_tests.cpp b/test/opaque_tests.cpp index ae5421d..384b3b8 100644 --- a/test/opaque_tests.cpp +++ b/test/opaque_tests.cpp @@ -1145,6 +1145,94 @@ TEST_CASE("a quotient of two units is not offered when its dimension would need CHECK(tariff->magnitudeDenominator == 3600000); } +TEST_CASE("a quotient of two units is offered only while its symbol fits a Symbol", "[opaque][trace]") +{ + // A mass over a volume, spelled in 15 bytes each: with the slash, 31 + // bytes, the most a Symbol holds before its terminator. + constexpr formula::Unit sampleMass { .dimension = formula::dim::Mass, + .symbolText = formula::symbol("AcmeSampleGrams") }; + constexpr formula::Unit vialVolume { .dimension = formula::dim::Volume, + .symbolText = formula::symbol("AcmeVialVolumes") }; + std::optional const fits = formula::detail::unit_quotient(sampleMass, vialVolume); + REQUIRE(fits.has_value()); + CHECK(formula::view(fits->symbolText) == "AcmeSampleGrams/AcmeVialVolumes"); + CHECK(formula::view(fits->symbolText).size() == formula::SymbolCapacity - 1); + CHECK(fits->dimension == formula::dim::Mass / formula::dim::Volume); + + // One byte more would leave no room for the terminator: no quotient is + // offered, rather than a truncated symbol. + constexpr formula::Unit longerMass { .dimension = formula::dim::Mass, + .symbolText = formula::symbol("AcmeSampleGrams2") }; + CHECK_FALSE(formula::detail::unit_quotient(longerMass, vialVolume).has_value()); +} + +TEST_CASE("a bracketed quotient, and a quotient's key, are offered only while they fit a Symbol", "[opaque][trace]") +{ + // The divisor is more than one unit word, so it is bracketed, and both + // brackets count: 15 + 1 + 13 + 2 bytes is 31, the most a Symbol holds. + constexpr formula::Unit sampleMass { .dimension = formula::dim::Mass, + .symbolText = formula::symbol("AcmeSampleGrams") }; + constexpr formula::Unit vialVolume { .dimension = formula::dim::Volume, + .symbolText = formula::symbol("Acme.VialVols") }; + std::optional const fits = formula::detail::unit_quotient(sampleMass, vialVolume); + REQUIRE(fits.has_value()); + CHECK(formula::view(fits->symbolText) == "AcmeSampleGrams/(Acme.VialVols)"); + CHECK(formula::view(fits->symbolText).size() == formula::SymbolCapacity - 1); + + // One byte more, and no quotient is offered. + constexpr formula::Unit longerMass { .dimension = formula::dim::Mass, + .symbolText = formula::symbol("AcmeSampleGrams2") }; + CHECK_FALSE(formula::detail::unit_quotient(longerMass, vialVolume).has_value()); + + // The key is held to the same limit on its own, brackets and all: a short + // symbol with a long key gives a quotient whose key is 31 bytes ... + constexpr formula::Unit keyedMass { .dimension = formula::dim::Mass, + .symbolText = formula::symbol("AcmeMass"), + .asciiText = formula::symbol("AcmeSampleGrams") }; + std::optional const keyFits = formula::detail::unit_quotient(keyedMass, vialVolume); + REQUIRE(keyFits.has_value()); + CHECK(formula::view(keyFits->symbolText) == "AcmeMass/(Acme.VialVols)"); + CHECK(formula::view_ascii(*keyFits) == "AcmeSampleGrams/(Acme.VialVols)"); + CHECK(formula::view_ascii(*keyFits).size() == formula::SymbolCapacity - 1); + + // ... and a key one byte longer refuses the quotient, though its symbol + // would still fit. + constexpr formula::Unit longerKeyedMass { .dimension = formula::dim::Mass, + .symbolText = formula::symbol("AcmeMass"), + .asciiText = formula::symbol("AcmeSampleGrams2") }; + CHECK_FALSE(formula::detail::unit_quotient(longerKeyedMass, vialVolume).has_value()); +} + +TEST_CASE("unit_quotient: a quotient of keyed units carries a quotient key", "[trace][unit][ascii]") +{ + std::optional const perSecond = formula::detail::unit_quotient(unit::Micrometre, unit::Second); + REQUIRE(perSecond.has_value()); + REQUIRE(formula::view(perSecond->symbolText) == "\xc2\xb5m/s"); + REQUIRE(formula::view_ascii(*perSecond) == "um/s"); + + // Only the divisor has a key: the key is built from both, and the + // display symbol keeps the micro sign. + std::optional const kilogramPerMicrometre = + formula::detail::unit_quotient(unit::Kilogram, unit::Micrometre); + REQUIRE(kilogramPerMicrometre.has_value()); + REQUIRE(formula::view(kilogramPerMicrometre->symbolText) == "kg/\xc2\xb5m"); + REQUIRE(formula::view_ascii(*kilogramPerMicrometre) == "kg/um"); + + // Neither operand has a key of its own: the quotient has none either, and view_ascii reads its symbol. + std::optional const metrePerSecond = formula::detail::unit_quotient(unit::Metre, unit::Second); + REQUIRE(metrePerSecond.has_value()); + REQUIRE(formula::view(metrePerSecond->asciiText).empty()); + REQUIRE(formula::view_ascii(*metrePerSecond) == "m/s"); + + // A key holding a slash reads two ways after another, as a symbol does: + // no quotient is offered. + constexpr formula::Unit slashedKey { .dimension = formula::dim::Mass / formula::dim::Volume, + .symbolText = formula::symbol("\xc2\xb5gL"), + .asciiText = formula::symbol("ug/L") }; + CHECK_FALSE(formula::detail::unit_quotient(slashedKey, unit::Second).has_value()); + CHECK_FALSE(formula::detail::unit_quotient(unit::Metre, slashedKey).has_value()); +} + TEST_CASE("an output's marker is judged by its operand step's kind, not by a row", "[opaque][trace]") { // A call's step and its output's, built by hand with no side tables: the diff --git a/test/quantity_tests.cpp b/test/quantity_tests.cpp index 8d5f5a7..7d1026b 100644 --- a/test/quantity_tests.cpp +++ b/test/quantity_tests.cpp @@ -136,3 +136,63 @@ TEST_CASE("a foreign type joins on the same terms as ours", "[quantity]") == formula::view(unit::Celsius.symbolText)); CHECK(Describe::dimension == Describe::unit.dimension); } + +// ---- a quantity's own decimal places ---- + +namespace +{ + +// Invented: milliamperes, declared to whole milliamperes. +inline constexpr formula::Unit Milliampere { .dimension = formula::dim::Current, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, + .symbolText = formula::symbol("mA"), + .decimals = 0 }; +struct FineCurrent: formula::Quantity +{ +}; +struct CoarseCurrent: formula::Quantity +{ +}; + +} // namespace + +TEST_CASE("Quantity: its own decimal places, in the unit it shares", "[quantity][decimals]") +{ + STATIC_REQUIRE(formula::Describe::unit.decimals == 1); + STATIC_REQUIRE(formula::Describe::unit.decimals == 0); + STATIC_REQUIRE(formula::same_unit(formula::Describe::unit, Milliampere)); + STATIC_REQUIRE(!(formula::Describe::unit == Milliampere)); + STATIC_REQUIRE(formula::Describe::unit == Milliampere); +} + +TEST_CASE("same_unit: what a unit is, not its decimals or bounds", "[unit][same_unit]") +{ + constexpr formula::Unit Base { .dimension = formula::dim::Length, .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, .symbolText = formula::symbol("mm") }; + constexpr formula::Unit OtherDecimals { .dimension = formula::dim::Length, .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, .symbolText = formula::symbol("mm"), + .decimals = 1, .bounds = formula::at_least(0, 1) }; + STATIC_REQUIRE(formula::same_unit(Base, OtherDecimals)); + STATIC_REQUIRE(!formula::same_unit(Base, formula::unit::Metre)); // magnitude + STATIC_REQUIRE(!formula::same_unit(formula::unit::Kelvin, formula::unit::Celsius)); // offset + constexpr formula::Unit Unreduced { .dimension = formula::dim::Length, .magnitudeNumerator = 2, + .magnitudeDenominator = 2000, .symbolText = formula::symbol("mm") }; + STATIC_REQUIRE(!formula::same_unit(Base, Unreduced)); // the factor as declared, not reduced + constexpr formula::Unit OtherSymbol { .dimension = formula::dim::Length, .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, .symbolText = formula::symbol("MM") }; + STATIC_REQUIRE(!formula::same_unit(Base, OtherSymbol)); + constexpr formula::Unit OtherKey { .dimension = formula::dim::Length, .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, .symbolText = formula::symbol("mm"), + .asciiText = formula::symbol("millimetre") }; + STATIC_REQUIRE(!formula::same_unit(Base, OtherKey)); + constexpr formula::Unit SameKeySpelledOut { .dimension = formula::dim::Length, .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, .symbolText = formula::symbol("mm"), + .asciiText = formula::symbol("mm") }; + STATIC_REQUIRE(formula::same_unit(Base, SameKeySpelledOut)); // the same key, declared or read off the symbol + constexpr formula::Unit Area { .dimension = formula::dim::Length * formula::dim::Length, + .magnitudeNumerator = 1, .magnitudeDenominator = 1000, + .symbolText = formula::symbol("mm") }; + STATIC_REQUIRE(!formula::same_unit(Base, Area)); // dimension +} diff --git a/test/rational_literal_tests.cpp b/test/rational_literal_tests.cpp index 5443ea5..81a0774 100644 --- a/test/rational_literal_tests.cpp +++ b/test/rational_literal_tests.cpp @@ -48,5 +48,12 @@ TEST_CASE("_r: the edges of what is exact", "[rational][literal]") { STATIC_REQUIRE(00.5_r == Rational { 1, 2 }); // a leading zero is fine once there is a point STATIC_REQUIRE(0e3_r == Rational {}); - STATIC_REQUIRE(1e-18_r == Rational { 1, 1'000'000'000'000'000'000 }); // the largest exact denominator + STATIC_REQUIRE(0e1001_r == Rational {}); // zero at any exponent, as parse_decimal_text reads "0e99999" + STATIC_REQUIRE(1e-18_r == Rational { 1, 1'000'000'000'000'000'000 }); +} + +TEST_CASE("_r: a literal wider than 64 bits", "[rational][literal]") +{ + STATIC_REQUIRE(12'345'678'901'234'567'890_r == 1'234'567'890_r * 10'000'000'000_r + 1'234'567'890_r); + STATIC_REQUIRE(0.000'000'000'000'000'000'1_r == *Rational::from_decimal(1, -19)); } diff --git a/test/rational_tests.cpp b/test/rational_tests.cpp index b9a4bbc..86db456 100644 --- a/test/rational_tests.cpp +++ b/test/rational_tests.cpp @@ -82,7 +82,7 @@ static_assert(Rational::from_decimal(45, -2)->denominator() == 20); static_assert(Rational::from_decimal(3, 0) == Rational { 3 }); static_assert(Rational::from_decimal(3, 2) == Rational { 300 }); static_assert(Rational::from_decimal(0, -5) == Rational { 0 }); -static_assert(!Rational::from_decimal(1, -19).has_value()); +static_assert(!Rational::from_decimal(1, -39).has_value()); static_assert(!Rational::from_decimal(IntMax, 1).has_value()); static_assert(Rational::from_decimal(Int64Max, 1) == Rational { Rational::Int { Int64Max } * 10 }); @@ -140,6 +140,16 @@ TEST_CASE("from_decimal is exact where from_double_exact is not", "[rational]") CHECK(fromBinary->to_double() == 0.45); } +TEST_CASE("from_decimal: exponents up to 38 either way", "[rational]") +{ + STATIC_REQUIRE(*Rational::from_decimal(1, -19) == Rational::make(1, *formula::detail::pow10_wide(19)).value()); + STATIC_REQUIRE(Rational::from_decimal(1, -38).has_value()); + STATIC_REQUIRE(Rational::from_decimal(1, -39).error() == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(Rational::from_decimal(1, 38).has_value()); + STATIC_REQUIRE(Rational::from_decimal(1, 39).error() == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(*Rational::from_decimal(10, -39) == *Rational::from_decimal(1, -38)); // trailing zeros fold first +} + TEST_CASE("from_double_exact rejects non-finite input", "[rational]") { double const infinity = std::numeric_limits::infinity(); diff --git a/test/record_trace_tests.cpp b/test/record_trace_tests.cpp index 5024035..3a4db92 100644 --- a/test/record_trace_tests.cpp +++ b/test/record_trace_tests.cpp @@ -333,14 +333,22 @@ TEST_CASE("a scope reported without its origin records none, and says so", "[rec TEST_CASE("a step costs what it cost before records were traced", "[record-trace]") { - // 1320 bytes, measured on cl and clang-cl (release, /MD) and on g++ 14 and - // clang++ 20 with libstdc++, all 64-bit. It was 1296 until `Rational` held - // its numerator and denominator in 128 bits: a `Rational` grew from 16 - // bytes to 32, and the one a step holds inline, its `std::optional` value, - // from 24 to 40 -- 16 bytes more -- and `lookupKeyHigh`, a count's top 64 - // bits, adds 8 more after `lookupKeyIsSigned`. A step's band, segment and - // covered range keep their declared 64-bit numerators and denominators, and - // its other values live in its vectors, so neither grew. Before that it was + // 1608 bytes, measured on cl and clang-cl (release, /MD) and on g++ 14 and + // clang++ 20 with libstdc++, all 64-bit. It was 1320 until a unit symbol + // held 32 bytes and a unit gained an ASCII key: a `Symbol` grew from 16 + // bytes to 32, so a `Dimension`'s four named-base symbols grew by 64 bytes, + // from 152 to 216, and a `Unit` by 112, from 248 to 360 -- 64 in its + // dimension, 16 in its symbol and 32 for its new key. One dimension and two + // units make 288 bytes more per step. The `BoundsEnd` flags that replaced a + // unit's one `bool` of declared bounds are a byte each and fit the padding + // that `bool` left, so `Bounds` stays 40 bytes. Before that it was 1296, + // until `Rational` held its numerator and denominator in 128 bits: a + // `Rational` grew from 16 bytes to 32, and the one a step holds inline, its + // `std::optional` value, from 24 to 40 -- 16 bytes more -- and + // `lookupKeyHigh`, a count's top 64 bits, adds 8 more after + // `lookupKeyIsSigned`. A step's band, segment and covered range keep their + // declared 64-bit numerators and denominators, and its other values live + // in its vectors, so neither grew. Before that it was // 1008, until a `Dimension` could carry named base dimensions and a step's // one dimension and two units grew by 96 bytes each. Records' per-step // facts -- the source, whether a replaced entry was empty, and the record's @@ -353,6 +361,6 @@ TEST_CASE("a step costs what it cost before records were traced", "[record-trace #if (defined(_ITERATOR_DEBUG_LEVEL) && _ITERATOR_DEBUG_LEVEL != 0) || defined(_GLIBCXX_DEBUG) SUCCEED("a checked standard library's containers change every step's size, so nothing is pinned here"); #else - STATIC_REQUIRE((sizeof(void*) != 8 || sizeof(formula::Step) == 1320)); + STATIC_REQUIRE((sizeof(void*) != 8 || sizeof(formula::Step) == 1608)); #endif } diff --git a/test/rounded_root_tests.cpp b/test/rounded_root_tests.cpp index 0ce9a68..9854823 100644 --- a/test/rounded_root_tests.cpp +++ b/test/rounded_root_tests.cpp @@ -182,6 +182,18 @@ TEST_CASE("rounded_sqrt reports overflow rather than a wrapped result", "[rounde STATIC_REQUIRE(outcome.error() == formula::ArithmeticError::Overflow); } +TEST_CASE("rounded_sqrt takes places from -18 to 18, as checked_round does", "[rounded_root]") +{ + using formula::detail::rounded_square_root; + // The root of 2 is irrational, so this is the digit-by-digit path, not checked_round's. + STATIC_REQUIRE(rounded_square_root(Rational { 2 }, DecimalPlaces { 18 }, RoundingMode::Floor).value() + == *Rational::from_decimal(1'414'213'562'373'095'048, -18)); + STATIC_REQUIRE(rounded_square_root(Rational { 2 }, DecimalPlaces { 19 }, RoundingMode::Floor).error() + == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(rounded_square_root(Rational { 2 }, DecimalPlaces { -19 }, RoundingMode::Floor).error() + == formula::ArithmeticError::Overflow); +} + TEST_CASE("rounded_sqrt reports overflow at the exact 2^128 edge of the whole part", "[rounded_root]") { // v * 10^4 for N/1000, with N = 34028236692093846346337460743176821145 diff --git a/test/trace_shown_unit_tests.cpp b/test/trace_shown_unit_tests.cpp index 80004a1..1a1beea 100644 --- a/test/trace_shown_unit_tests.cpp +++ b/test/trace_shown_unit_tests.cpp @@ -918,3 +918,54 @@ TEST_CASE("a Measured value in a unit with no symbol reads in number_text and st CHECK(std::format("{:~HalfEven}", formula::Measured { Rational { 1, 3 } }) == "\xe2\x89\x88" "0.0003 kg"); } + +namespace +{ +// Invented: milliamperes, declared to whole milliamperes, and a quantity in +// them that declares one place of its own. +inline constexpr formula::Unit Milliampere { .dimension = formula::dim::Current, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, + .symbolText = formula::symbol("mA"), + .decimals = 0 }; +struct FineCurrent: formula::Quantity +{ +}; +} // namespace + +TEST_CASE("a quantity's own decimal places are the places its trace shows", "[trace-render][shown-unit][decimals]") +{ + // Milliamperes are declared to whole ones; the quantity reads them to a + // tenth, and so does its trace, the input and the product it scales. + formula::Trace<> const scaled = + recorded_trace(var * Rational { 2 }, + formula::environment(formula::Measured { Rational { 1234, 100 } })); + REQUIRE(scaled.steps.size() == 3); + CHECK(formula::view(scaled.steps[0].unit.symbolText) == "mA"); + CHECK(scaled.steps[0].unit.decimals == 1); + CHECK(formula::view(scaled.steps[2].unit.symbolText) == "mA"); + CHECK(scaled.steps[2].unit.decimals == 1); + + constexpr formula::NumberStyle padded = formula::NumberStyle::exact_decimal(formula::DecimalPadding::Padded); + constexpr formula::NumberStyle approximately = + formula::NumberStyle::approximate_decimal(formula::RoundingMode::HalfEven); + + formula::Trace<> const whole = + recorded_trace(var * Rational { 2 }, + formula::environment(formula::Measured { Rational { 12 } })); + CHECK(formula::render_trace(whole, { .maxSteps = 20, .numbers = padded }) + == "1. I_f = 12.0 mA\n" + "2. 2\n" + "3. #1 * #2 = 24.0 mA\n"); + + // 37/3 mA and twice it, 74/3 mA, are 12.333... and 24.666... mA: each + // rounded to the quantity's one place. + formula::Trace<> const third = + recorded_trace(var * Rational { 2 }, + formula::environment(formula::Measured { Rational { 37, 3 } })); + CHECK(formula::render_trace(third, { .maxSteps = 20, .numbers = approximately }) + == "1. I_f = \xe2\x89\x88" "12.3 mA\n" + "2. 2\n" + "3. #1 * #2 = \xe2\x89\x88" "24.7 mA\n"); +} diff --git a/test/unit_cross_tu.hpp b/test/unit_cross_tu.hpp index 5dd52e8..b073a3a 100644 --- a/test/unit_cross_tu.hpp +++ b/test/unit_cross_tu.hpp @@ -8,7 +8,7 @@ /// named from unit::Litre. If the two spellings are not the same type, this /// does not link. /// -/// Unit nests Symbol (a 16-byte char array) and Bounds inside the NTTP, a +/// Unit nests Symbol (a 32-byte char array) and Bounds inside the NTTP, a /// strictly richer mangling than Dimension's, and `Quantity<…, Unit>` is the /// consumer that depends on this holding. diff --git a/test/unit_tests.cpp b/test/unit_tests.cpp index 123d633..ecee87f 100644 --- a/test/unit_tests.cpp +++ b/test/unit_tests.cpp @@ -3,7 +3,10 @@ #include +#include #include +#include +#include #include #include @@ -117,8 +120,8 @@ TEST_CASE("reading a symbol never runs off the end of its storage", "[unit]") // directly, and exactly SymbolCapacity bytes of text leaves no room for a // terminator. An unbounded scan then reads whatever follows in memory. The // neighbouring array is here so that a regression has something to run into: - // before `view()` was bounded, this returned 23 characters from a 16-byte - // array. + // before `view()` was bounded, this returned 23 characters from what was + // then a 16-byte array. struct Adjacent { formula::Symbol symbolText; @@ -144,6 +147,63 @@ TEST_CASE("reading a symbol never runs off the end of its storage", "[unit]") CHECK(formula::view(empty).empty()); } +TEST_CASE("checked_symbol: run-time text that fits is kept byte for byte", "[unit][symbol]") +{ + // µmol/(L·min·kg): 18 bytes of UTF-8. + constexpr std::string_view compound = "\xc2\xb5mol/(L\xc2\xb7min\xc2\xb7kg)"; + STATIC_REQUIRE(compound.size() == 18); + constexpr std::expected built = formula::checked_symbol(compound); + STATIC_REQUIRE(built.has_value()); + STATIC_REQUIRE(formula::view(*built) == compound); + + // 31 bytes, the most that fits, ending in a two-byte character: kept whole. + constexpr std::string_view widest = "abcdefghijklmnopqrstuvwxyz012\xc2\xb5"; + STATIC_REQUIRE(widest.size() == formula::SymbolCapacity - 1); + constexpr std::expected widestBuilt = formula::checked_symbol(widest); + STATIC_REQUIRE(widestBuilt.has_value()); + STATIC_REQUIRE(formula::view(*widestBuilt) == widest); + + constexpr std::expected empty = formula::checked_symbol(""); + STATIC_REQUIRE(empty.has_value()); + STATIC_REQUIRE(formula::view(*empty).empty()); + + // The same at run time, from text the compiler cannot see. + std::string const fromCatalogue { compound }; + std::expected const atRunTime = formula::checked_symbol(fromCatalogue); + REQUIRE(atRunTime.has_value()); + REQUIRE(formula::view(*atRunTime) == compound); +} + +TEST_CASE("checked_symbol: text that does not fit, or holds a NUL, is refused", "[unit][symbol]") +{ + constexpr std::string_view tooLong = "abcdefghijklmnopqrstuvwxyz0123\xc2\xb5"; // 32 bytes + STATIC_REQUIRE(tooLong.size() == formula::SymbolCapacity); + STATIC_REQUIRE(formula::checked_symbol(tooLong).error() == formula::SymbolError::TooLong); + + constexpr std::string_view withNull { "mg\0L", 4 }; + STATIC_REQUIRE(formula::checked_symbol(withNull).error() == formula::SymbolError::EmbeddedNull); +} + +TEST_CASE("describe(SymbolError) names each refusal", "[unit][symbol]") +{ + STATIC_REQUIRE(formula::describe(formula::SymbolError::TooLong) + == "the symbol does not fit SymbolCapacity bytes, terminator included"); + STATIC_REQUIRE(formula::describe(formula::SymbolError::EmbeddedNull) == "the symbol contains a NUL byte"); + STATIC_REQUIRE(formula::describe(formula::SymbolError::NotAscii) + == "the symbol holds a byte outside printable ASCII"); +} + +TEST_CASE("symbol(): a compound UTF-8 laboratory unit fits", "[unit][symbol]") +{ + constexpr formula::Symbol compound = formula::symbol("\xc2\xb5mol/(L\xc2\xb7min\xc2\xb7kg)"); + STATIC_REQUIRE(formula::view(compound).size() == 18); + + // 31 bytes, the most that fits, ending in a two-byte character: kept whole. + constexpr formula::Symbol widestLiteral = formula::symbol("abcdefghijklmnopqrstuvwxyz012\xc2\xb5"); + STATIC_REQUIRE(formula::view(widestLiteral).size() == formula::SymbolCapacity - 1); + STATIC_REQUIRE(formula::view(widestLiteral).ends_with("\xc2\xb5")); +} + TEST_CASE("units report a readable symbol", "[unit]") { CHECK(formula::view(unit::Litre.symbolText) == std::string_view { "l" }); @@ -798,8 +858,11 @@ inline constexpr Unit BoundedPercent { .dimension = dim::Scalar, .bounds = formula::bounds(0, 1, 100, 1) }; } // namespace -static_assert(!unit::Litre.bounds.present); -static_assert(BoundedPercent.bounds.present); +static_assert(!unit::Litre.bounds.lowPresent && !unit::Litre.bounds.highPresent); +static_assert(BoundedPercent.bounds.lowPresent && BoundedPercent.bounds.highPresent); +// A positional list of the low flag alone still compiles, and declares a minimum of 0 and no maximum. A longer +// positional list whose second value is a number does not (negative/bounds_positional_initialiser.cpp). +static_assert(formula::Bounds { true } == formula::at_least(0, 1)); static_assert(*formula::checked_within_bounds(*Rational::make(50, 1), BoundedPercent) == BoundsCheck::WithinBounds); @@ -973,20 +1036,89 @@ TEST_CASE("a malformed unit refuses to answer rather than answer wrong", "[unit] Unit const invertedByAggregate { .dimension = dim::Scalar, .symbolText = formula::symbol("agg"), .decimals = 0, - .bounds = { true, 100, 1, 0, 1 } }; + .bounds = { .lowPresent = true, + .highPresent = true, + .lowNumerator = 100, + .lowDenominator = 1, + .highNumerator = 0, + .highDenominator = 1 } }; auto const aggregateResult = formula::checked_within_bounds(*Rational::make(50, 1), invertedByAggregate); REQUIRE_FALSE(aggregateResult.has_value()); CHECK(aggregateResult.error() == ArithmeticError::DomainError); - // And an inverted range that was never declared present is still simply + // And an inverted range with neither end declared present is still simply // unchecked: a unit nobody gave bounds to must not start reporting errors. Unit const invertedButAbsent { .dimension = dim::Scalar, .symbolText = formula::symbol("abs"), .decimals = 0, - .bounds = { false, 100, 1, 0, 1 } }; + .bounds = { .lowPresent = false, + .highPresent = false, + .lowNumerator = 100, + .lowDenominator = 1, + .highNumerator = 0, + .highDenominator = 1 } }; CHECK(unwrapped(formula::checked_within_bounds(*Rational::make(50, 1), invertedButAbsent)) == BoundsCheck::NotChecked); } +TEST_CASE("checked_within: either end, both, or neither", "[unit][bounds]") +{ + using formula::BoundsCheck; + using formula::Rational; + constexpr std::optional none {}; + STATIC_REQUIRE(*formula::checked_within(Rational { 5 }, Rational { 0 }, none) == BoundsCheck::WithinBounds); + STATIC_REQUIRE(*formula::checked_within(Rational { -1 }, Rational { 0 }, none) == BoundsCheck::BelowMinimum); + STATIC_REQUIRE(*formula::checked_within(Rational { 0 }, Rational { 0 }, none) == BoundsCheck::WithinBounds); + STATIC_REQUIRE(*formula::checked_within(Rational { 21 }, none, Rational { 20 }) == BoundsCheck::AboveMaximum); + STATIC_REQUIRE(*formula::checked_within(Rational { 20 }, none, Rational { 20 }) == BoundsCheck::WithinBounds); + STATIC_REQUIRE(*formula::checked_within(Rational { 7 }, Rational { 0 }, Rational { 20 }) == BoundsCheck::WithinBounds); + STATIC_REQUIRE(*formula::checked_within(Rational { 7 }, none, none) == BoundsCheck::NotChecked); + STATIC_REQUIRE(formula::checked_within(Rational { 7 }, Rational { 20 }, Rational { 0 }).error() + == formula::ArithmeticError::DomainError); + REQUIRE(formula::within(Rational { 7 }, none, Rational { 5 }) == BoundsCheck::AboveMaximum); + REQUIRE_THROWS_AS(formula::within(Rational { 7 }, Rational { 9 }, Rational { 5 }), formula::ArithmeticException); +} + +TEST_CASE("checked_within_bounds: a unit declared with at_least or at_most", "[unit][bounds]") +{ + using formula::BoundsCheck; + using formula::Rational; + constexpr formula::Unit NonNegative { .dimension = formula::dim::Scalar, + .symbolText = formula::symbol("x"), + .bounds = formula::at_least(0, 1) }; + constexpr formula::Unit AtMostTwenty { .dimension = formula::dim::Scalar, + .symbolText = formula::symbol("y"), + .bounds = formula::at_most(20, 1) }; + STATIC_REQUIRE(NonNegative.bounds.lowPresent && !NonNegative.bounds.highPresent); + STATIC_REQUIRE(*formula::checked_within_bounds(Rational { 1'000'000 }, NonNegative) == BoundsCheck::WithinBounds); + STATIC_REQUIRE(*formula::checked_within_bounds(Rational { -1, 2 }, NonNegative) == BoundsCheck::BelowMinimum); + STATIC_REQUIRE(*formula::checked_within_bounds(Rational { -1'000'000 }, AtMostTwenty) == BoundsCheck::WithinBounds); + STATIC_REQUIRE(*formula::checked_within_bounds(Rational { 41, 2 }, AtMostTwenty) == BoundsCheck::AboveMaximum); + + // A declared end is built with Rational::make, so a zero denominator on a + // one-sided unit is refused as it is on a two-sided one. + constexpr formula::Unit ZeroDenominatorMinimum { .dimension = formula::dim::Scalar, + .symbolText = formula::symbol("zmin"), + .bounds = formula::at_least(1, 0) }; + constexpr formula::Unit ZeroDenominatorMaximum { .dimension = formula::dim::Scalar, + .symbolText = formula::symbol("zmax"), + .bounds = formula::at_most(1, 0) }; + STATIC_REQUIRE(formula::checked_within_bounds(Rational { 5 }, ZeroDenominatorMinimum).error() + == formula::ArithmeticError::DivisionByZero); + STATIC_REQUIRE(formula::checked_within_bounds(Rational { 5 }, ZeroDenominatorMaximum).error() + == formula::ArithmeticError::DivisionByZero); + + // An undeclared end's fields are ignored: its zero denominator is never + // built, so the declared minimum alone answers. + constexpr formula::Unit UndeclaredMaximum { .dimension = formula::dim::Scalar, + .symbolText = formula::symbol("umax"), + .bounds = { .lowPresent = true, + .lowNumerator = 0, + .lowDenominator = 1, + .highDenominator = 0 } }; + STATIC_REQUIRE(*formula::checked_within_bounds(Rational { 5 }, UndeclaredMaximum) == BoundsCheck::WithinBounds); + STATIC_REQUIRE(*formula::checked_within_bounds(Rational { -5 }, UndeclaredMaximum) == BoundsCheck::BelowMinimum); +} + // ---- cross-translation-unit identity ---- #include "unit_cross_tu.hpp" @@ -1113,3 +1245,59 @@ TEST_CASE("a dimensionless unit with a scale and no symbol is the one a declarat STATIC_REQUIRE(formula::detail::RequireNamedScaledScalar::value); STATIC_REQUIRE(formula::detail::RequireNamedScaledScalar::value); } + +// ---- the ASCII key ---- + +TEST_CASE("view_ascii: a unit's key, or its symbol when that is ASCII", "[unit][ascii]") +{ + STATIC_REQUIRE(formula::view_ascii(unit::Millimetre) == "mm"); + STATIC_REQUIRE(formula::view_ascii(unit::PerMille) == "permille"); + STATIC_REQUIRE(formula::view_ascii(unit::Micrometre) == "um"); + STATIC_REQUIRE(formula::view_ascii(unit::Celsius) == "degC"); + STATIC_REQUIRE(formula::view_ascii(unit::Fahrenheit) == "degF"); + // Bound to a name first: `view_ascii` of a temporary is deleted, as `view` of one is. + constexpr Unit coherentMass = formula::coherent(dim::Mass); + STATIC_REQUIRE(formula::view_ascii(coherentMass).empty()); + // The display symbol is unchanged. + STATIC_REQUIRE(formula::view(unit::Micrometre.symbolText) == "\xc2\xb5m"); +} + +TEST_CASE("has_ascii_key: false for a non-ASCII symbol without a key, and for a non-ASCII key", "[unit][ascii]") +{ + constexpr Unit MicrogramPerLitreUnkeyed { .dimension = dim::Mass / dim::Volume, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1'000'000, + .symbolText = formula::symbol("\xc2\xb5g/L") }; + STATIC_REQUIRE(!formula::has_ascii_key(MicrogramPerLitreUnkeyed)); + + constexpr Unit MicrogramPerLitre { .dimension = dim::Mass / dim::Volume, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1'000'000, + .symbolText = formula::symbol("\xc2\xb5g/L"), + .asciiText = formula::symbol("ug/L") }; + STATIC_REQUIRE(formula::has_ascii_key(MicrogramPerLitre)); + STATIC_REQUIRE(formula::view_ascii(MicrogramPerLitre) == "ug/L"); + + // An ASCII symbol with a key that is not ASCII is refused like a missing key: the key is what a serialiser trusts. + constexpr Unit BadKey { .dimension = dim::Length, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1'000'000, + .symbolText = formula::symbol("um"), + .asciiText = formula::symbol("\xc2\xb5m") }; + STATIC_REQUIRE(!formula::has_ascii_key(BadKey)); + STATIC_REQUIRE(formula::has_ascii_key(unit::Millimetre)); +} + +TEST_CASE("checked_ascii_symbol: printable ASCII only", "[unit][ascii]") +{ + // Bound to a name first: `view` of the temporary would be the deleted `view(Symbol&&)`. + constexpr std::expected asciiKey = formula::checked_ascii_symbol("ug/L"); + STATIC_REQUIRE(asciiKey.has_value()); + STATIC_REQUIRE(formula::view(*asciiKey) == "ug/L"); + STATIC_REQUIRE(formula::checked_ascii_symbol("\xc2\xb5g/L").error() == formula::SymbolError::NotAscii); + STATIC_REQUIRE(formula::checked_ascii_symbol("tab\there").error() == formula::SymbolError::NotAscii); + STATIC_REQUIRE(formula::checked_ascii_symbol("abcdefghijklmnopqrstuvwxyz012345").error() + == formula::SymbolError::TooLong); + STATIC_REQUIRE(formula::checked_ascii_symbol(std::string_view { "u\0g", 3 }).error() + == formula::SymbolError::EmbeddedNull); +}