diff --git a/CHANGELOG.md b/CHANGELOG.md index f1779d5e..b2fed76f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,46 @@ change is recorded here. ## [Unreleased] +### Added + +- **`formula::Int128`** (`int128.hpp`), a signed 128-bit integer with one API on every compiler: the compiler's own + 128-bit integer computes where it has one (GCC, Clang), portable `constexpr` code everywhere else (cl, clang-cl). + It converts to no built-in integer implicitly or explicitly; `to_int64()` and `to_uint64()` say when a value does + not fit. `std::format` writes it in decimal, and `std::numeric_limits` states its limits as a + built-in signed integer's are. + +### Changed + +- **Every computed value in a trace shows a unit.** A value scaled by a pure number, and a sum or difference of + values shown in one unit, read in that unit: the outlier-rejection limit `#2 * #3 = 1239/500000` is now + `#2 * #3 = 1239/500 g`. A negation and an absolute value read in their operand's unit, and a conditional and a + precision limit in the unit of the step they restate. An offset unit is never borrowed for a sum, difference, + scaling or negation: the difference of two Celsius readings reads in `K`. Any other dimensioned value is shown in + the coherent unit, followed by its spelling from the base units (`427/125000000 kg^2`, `60000000 kg/(m s^2)`), and + so is a value in a unit that has no symbol. A dimensionless value is still a bare number. A trace text pinned in + a test changes wherever it showed a dimensioned value bare. `Step::unit` of a scaled, summed, negated, + absolute-value, conditional or precision-limit step now holds the unit it borrowed, so code that reads steps sees the unit the + trace text names. +- A snap's permitted values, a binning's classes, a lookup's bands and rows and a curve's rows, declared in a unit + that has no symbol, are written in the coherent unit with its spelling, as the value beside them is, rather than + as numbers in a scale the line does not name. +- **`Rational` stores its numerator and denominator in `formula::Int128`**, so `Rational::Int` is `Int128` and a + `Rational` is 32 bytes. Realistic laboratory statistics that overflowed 64 bits now answer: the sample variance of + masses read to 6 decimal places of a gram, rejection by standard deviations at that resolution, and a cylinder's + strength at every diameter measured (`docs/numeric-headroom.md`). Code that stored `numerator()` or `denominator()` + in a built-in integer must narrow with `to_int64()`. The `_r` literal, `Rational::from_decimal`'s exponents and + rounding's decimal places keep their limits of 18. +- `rounded_sqrt` computes in 128 bits, and answers at more places before it reports `Overflow`. +- `NumberTextCapacity` is 128, so that a 39-digit numerator over a 39-digit denominator fits a `NumberText`. +- `band(Rational, Rational)` and `breakpoint(Rational)` refuse a bound or key that does not fit their 64-bit fields: + in a constant expression it fails to compile, naming `formula_band_bound_out_of_range` or + `formula_breakpoint_key_out_of_range`; reached at run time, it ends the program, because a `Band` or a + `Breakpoint` is a template argument, built at compile time, and has no way to carry a failure. +- `Step` gains `lookupKeyHigh`, bits 64 to 127 of the count a sample-size lookup selected with, so that a miss on a + count above 2^64 - 1 names the whole count. Code that reads `lookupKey` for such a step reads both. +- `Rational`'s converting constructor takes every built-in integer type of at most 64 bits except `bool`, exactly, + `std::uint64_t` now among them; a wider built-in integer is refused. A constructor from `Int128` is added. + ## [0.3.0] - 2026-10-01 The third release. It gives a shorter spelling to everything the examples repeated, and takes no diff --git a/CMakeLists.txt b/CMakeLists.txt index 5ef74824..e481afea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -72,6 +72,7 @@ target_sources(formula-cpp INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include/formula-cpp/format.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/include/formula-cpp/formula.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/include/formula-cpp/function.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/include/formula-cpp/int128.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/include/formula-cpp/least_squares.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/include/formula-cpp/lineage.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/include/formula-cpp/lookup.hpp" @@ -210,7 +211,7 @@ if(FORMULA_BUILD_TESTS AND FORMULA_BUILD_EXAMPLES AND FORMULA_TOOLS) add_test(NAME census.exact-sizes COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/census/exact_sizes.py") set_tests_properties(census.exact-sizes PROPERTIES PASS_REGULAR_EXPRESSION - "at 6 dp: the exact variance does not fit 64 bits in 374 of 1000 in kg2 \\(SI\\), in 0 of 1000 in g2 \\(declared. widest 45 bits\\)[\r\n]+[^\r\n]*at d = 101 \\(64 bits\\), 103 \\(64 bits\\), 107 \\(64 bits\\), 109 \\(64 bits\\), 113 \\(64 bits\\), 119 \\(64 bits\\), 121 \\(64 bits\\), 127 \\(64 bits\\), 131 \\(64 bits\\), 137 \\(64 bits\\), 139 \\(64 bits\\), 143 \\(64 bits\\), 149 \\(64 bits\\), 151 \\(64 bits\\), 157 \\(64 bits\\), 161 \\(64 bits\\), 163 \\(64 bits\\)[\r\n]+[^\r\n]*in MPa \\(declared\\) it does not fit at 0 of 63 \\(widest 44 bits\\)") + "at 6 dp: the exact variance does not fit 128 bits in 0 of 1000 in kg2 \\(SI. widest 65 bits\\), in 0 of 1000 in g2 \\(declared. widest 45 bits\\)[\r\n]+[^\r\n]*in Pa \\(SI\\) the exact strength does not fit 128 bits at 0 of 63 \\(widest 64 bits\\)[\r\n]+[^\r\n]*in MPa \\(declared\\) it does not fit at 0 of 63 \\(widest 44 bits\\)") endif() add_test(NAME docs.numeric-headroom COMMAND "${CMAKE_COMMAND}" ${censusPageArguments} -P "${PROJECT_SOURCE_DIR}/cmake/CheckCensusPage.cmake") diff --git a/README.md b/README.md index 6069f313..b737c760 100644 --- a/README.md +++ b/README.md @@ -234,18 +234,21 @@ std::print("{}", formula::render_trace(explained.trace, { .maxSteps = 10 })); 4. #3 = 3/5 [Water/cement ratio, Example Standard 1:2020, 5.4.2, (3)] ``` -Every value is shown in the unit it was declared in, not the coherent unit -the arithmetic actually ran on — that is `9/50` cubic metres above, and nobody -typed cubic metres. When the environment overrides the result instead of -letting the formula derive it, `explained.trace` comes back empty — nothing -ran, so nothing was recorded — and `explained.outcome.is_overridden()` says -so instead: an overridden number shows *that a person entered it*, a -different fact from how it was reached and arguably a more important one. -See [the tracing guide](docs/tracing.md) for the detail. Tracing costs -nothing when nobody asks for it: a sink is -passed by value, and the untraced path — `evaluate()`, `checked_evaluate()` — -defaults to one that does nothing, adding no instruction the evaluator would -not already emit once the call inlines, measured on all four compilers this +Every value is shown in the unit written after it: an input in the unit it was +declared in, not the coherent unit the arithmetic actually ran on — that is +`9/50` cubic metres above, and nobody typed cubic metres. A computed value +borrows the unit of the values it was computed from where that is safe, and is +otherwise shown in the coherent unit, spelt from the base units (`kg/m^3`); +only a dimensionless value, like the ratio above, is a bare number. When the +environment overrides the result instead of letting the formula derive it, +`explained.trace` comes back empty — nothing ran, so nothing was recorded — +and `explained.outcome.is_overridden()` says so instead: an overridden number +shows *that a person entered it*, a different fact from how it was reached and +arguably a more important one. See [the tracing guide](docs/tracing.md) for +the detail. Tracing costs nothing when nobody asks for it: a sink is passed by +value, and the untraced path — `evaluate()`, `checked_evaluate()` — defaults +to one that does nothing, adding no instruction the evaluator would not +already emit once the call inlines, measured on all four compilers this library targets. See [the tracing guide](docs/tracing.md). ### A published table that a value falls outside of gives no number at all diff --git a/cmake/CheckCensusPage.cmake b/cmake/CheckCensusPage.cmake index a4d2df29..aa95d1f2 100644 --- a/cmake/CheckCensusPage.cmake +++ b/cmake/CheckCensusPage.cmake @@ -74,7 +74,7 @@ census_block(regression regressionTable) # ---- The census twins --------------------------------------------------------------- set(examplesTable "| program | numerator bits | denominator bits | intermediate bits | headroom |\n|---|---|---|---|---|\n") -set(censusLine "overflow census: numerator ([0-9]+) bits, denominator ([0-9]+) bits, intermediate ([0-9]+) bits, unsigned ([0-9]+) bits; headroom ([0-9]+) of 63") +set(censusLine "overflow census: numerator ([0-9]+) bits, denominator ([0-9]+) bits, intermediate ([0-9]+) bits, unsigned ([0-9]+) bits; headroom ([0-9]+) of 127") function(twin_row label program outVariable) execute_process( diff --git a/docs/calculations.md b/docs/calculations.md index 0ca7b5fa..34242029 100644 --- a/docs/calculations.md +++ b/docs/calculations.md @@ -545,9 +545,9 @@ In it, a calculated value it reads is one step, marked `calculated`: daily_load = fridge_kwh + oven_kwh + heater_kwh = 13.3 kWh 1. fridge_kwh = 4.8 kWh, calculated 2. oven_kwh = 2.5 kWh, calculated - 3. #1 + #2 = 26280000 + 3. #1 + #2 = 7.3 kWh 4. heater_kwh = 6 kWh, calculated - 5. #3 + #4 = 47880000 + 5. #3 + #4 = 13.3 kWh ``` The blocks of the calculated values it reads follow it, the last calculated @@ -559,7 +559,7 @@ the fridge's energy reads. The inputs read come last, one line each: fridge_kwh = fridge_kw * fridge_h = 4.8 kWh 1. fridge_kw = 0.4 kW, calculated 2. fridge_h = 12 h - 3. #1 * #2 = 17280000 + 3. #1 * #2 = 17280000 m^2 kg/s^2 fridge_kw = fridge_w = 0.4 kW 1. fridge_w = 400 W inputs @@ -594,9 +594,11 @@ Asking for `Q` brings it up to date, as `checked_calculate` does, and counts as it does; recording the blocks calculates nothing again. Here nothing was out of date, and the example checks that neither counter moved. -A computed step states its value in the coherent unit of its dimension, as -every trace does: the fridge's 4.8 kWh reads `17280000` there, in joules, -under a header in kilowatt-hours. +A computed step states its value in the unit its trace step is shown in, as +every trace does: a unit borrowed from the steps it read where that is safe, +and otherwise the coherent unit of its dimension, spelt from its base units. A +power times a time borrows neither one's unit, so the fridge's 4.8 kWh reads +`17280000 m^2 kg/s^2` there, in joules, under a header in kilowatt-hours. ## A value typed in by hand @@ -629,7 +631,7 @@ std::string const gridText = formula::render_derivation(gridCost, { .maxSteps = grid_cost = net_draw * price = 62.5 EUR 1. net_draw = 250 kWh, entered by hand 2. price = 0.25 EUR/kWh - 3. #1 * #2 = 62.5 + 3. #1 * #2 = 62.5 EUR net_draw = 250 kWh, entered by hand in place of monthly_load - self_used ... 1 further step not shown ``` diff --git a/docs/dimensions.md b/docs/dimensions.md index dd1aac9e..a45aa2d3 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -445,14 +445,14 @@ which is why a dimension should only ever be built with `base_dimension` and the operators. **In a coherent unit, the name is the symbol.** A computed step in a trace -carries no unit symbol of its own (see -[Tracing and audit trails](tracing.md#reading-a-derivation)), but where the -trace does spell a coherent unit out -- for an opaque operation's output that -no input's unit fits ([Opaque operations and bounded retry](opaque-and-retry.md)) --- a named base is written by its name, ahead of the SI units on its side of -the slash: `EUR s^2/(m^2 kg)` for euros per joule, then `1/JPY`, `EUR/JPY`, -`EUR^(1/2)`. The money comes first because a tariff is read as money per -energy. +that has no unit to borrow from the steps it read is shown in the coherent +unit, and the trace spells that unit out after its number (see +[Tracing and audit trails](tracing.md#reading-a-derivation)) -- as it does for +an opaque operation's output that no input's unit fits +([Opaque operations and bounded retry](opaque-and-retry.md)). A named base is +written by its name, ahead of the SI units on its side of the slash: +`EUR s^2/(m^2 kg)` for euros per joule, then `1/JPY`, `EUR/JPY`, `EUR^(1/2)`. +The money comes first because a tariff is read as money per energy. ## Limits @@ -498,7 +498,8 @@ filled by hand is checked by none of them. `Unit`'s `magnitudeNumerator`, `magnitudeDenominator`, `offsetNumerator`, `offsetDenominator` and the four fields of `Bounds` are all -`std::int64_t`, the same width as `Rational`'s own numerator and denominator. +`std::int64_t`. `Rational`'s own numerator and denominator are 128-bit, so every +value these fields state converts to one exactly. Conversion is built on `formula::Rational` and the `checked_` arithmetic functions, so it inherits their overflow behaviour and rounding limits diff --git a/docs/display.md b/docs/display.md index 842c8bfa..006bdc69 100644 --- a/docs/display.md +++ b/docs/display.md @@ -101,10 +101,10 @@ std::string const padded = formula::render_trace(moisture->trace, { .maxSteps = -- fractions, the default -- 1. m_w = 787/5 g 2. m_d = 144 g -3. #1 - #2 = 67/5000 +3. #1 - #2 = 67/5 g 4. m_d = 144 g 5. 51/2 g -6. #4 - #5 = 237/2000 +6. #4 - #5 = 237/2 g 7. #3 / #6 = 134/1185 ``` @@ -112,10 +112,10 @@ std::string const padded = formula::render_trace(moisture->trace, { .maxSteps = -- exact decimals -- 1. m_w = 157.4 g 2. m_d = 144 g -3. #1 - #2 = 0.0134 +3. #1 - #2 = 13.4 g 4. m_d = 144 g 5. 25.5 g -6. #4 - #5 = 0.1185 +6. #4 - #5 = 118.5 g 7. #3 / #6 = 134/1185 ``` @@ -123,10 +123,10 @@ std::string const padded = formula::render_trace(moisture->trace, { .maxSteps = -- rounded where no decimal ends -- 1. m_w = 157.4 g 2. m_d = 144 g -3. #1 - #2 = 0.0134 +3. #1 - #2 = 13.4 g 4. m_d = 144 g 5. 25.5 g -6. #4 - #5 = 0.1185 +6. #4 - #5 = 118.5 g 7. #3 / #6 = ≈0.113 ``` @@ -134,10 +134,10 @@ std::string const padded = formula::render_trace(moisture->trace, { .maxSteps = -- rounded and padded -- 1. m_w = 157.4 g 2. m_d = 144.0 g -3. #1 - #2 = 0.0134 +3. #1 - #2 = 13.4 g 4. m_d = 144.0 g 5. 25.5 g -6. #4 - #5 = 0.1185 +6. #4 - #5 = 118.5 g 7. #3 / #6 = ≈0.113 ``` @@ -166,24 +166,74 @@ declares, as a line of another block that reads the value states it -- rounded, padded or exact alike. The block's own last step agrees with the header on whether the value is typed, and a typed value is exact on every one of those lines. That is all they agree on: where the last step computed the -value, it states it in the coherent unit, so it may differ from the header in -its unit, its padding and its decimals, and one may read `≈` where the other -does not. The header's definition is written as a rendered formula is (see -below), its typed numbers exact. +value, it states it in the unit that step is shown in, which may be the +coherent unit (below), so it may differ from the header in its unit, its +padding and its decimals, and one may read `≈` where the other does not. The +header's definition is written as a rendered formula is (see below), its typed +numbers exact. ### A value in a unit nobody declared -Line 3 reads `0.0134`, with no unit. A value the arithmetic computed -- a -difference, a product, a ratio -- is stated in the coherent unit of its -dimension, here the kilogram: `0.0134` is the 13.4 g the specimen lost. Nobody -declared that unit for this formula, so its decimals are `Unit`'s default of 3, -which is no one's statement of precision. Such a value is **never padded** -- -the padded trace in the next section writes a computed 0.12 kg as `0.12`, not -`0.120` -- and when it is rounded it keeps those **3 places** (bar one -exception, below): line 7's ratio reads `≈0.113`. +Most computed values read in a unit someone declared. Lines 3 and 6 above, +differences of two masses in grams, read in grams, as a value scaled by a pure +number does: a computed step borrows the unit of the steps it read where that +is safe ([Reading a derivation](tracing.md#reading-a-derivation)). A product +or a quotient of two dimensioned values borrows nothing, even of two values in +one unit: a length times a length is no length, and a length over a time +neither. It is stated in the **coherent unit** of its dimension, followed by +that unit's spelling from the base units. A bearing plate's area, from its two +edges in millimetres: -Three places of a kilogram can hide almost everything. The dish's mass, the -mean of three weighings in grams, is computed in kilograms: +```cpp +// A bearing plate's area: a product of two lengths, which borrows neither one's unit. +inline constexpr auto plateArea = var * var; +``` + +Its trace, rendered in the rounded and padded style: + +```text +1. l_p = 100.0 mm +2. b_p = 200.0 mm +3. #1 * #2 = 0.02 m^2 +``` + +Line 3 is in square metres, written `m^2` after it, though nobody declared that +unit for this formula. Its decimals are `Unit`'s default of 3, which is no +one's statement of precision, so such a value is **never padded**: the edges +are padded to the millimetre's one decimal, and the area reads `0.02`, not +`0.020`. Read in the unit its quantity declares, the result keeps what +matters: + +```text +the plate's area in its declared square millimetres: 20000 mm2 +``` + +When a value in the coherent unit is rounded it keeps those 3 places -- unless +they round a value other than zero to `≈0`, which says nothing of it. They are +then extended to its first significant digit, up to 18 places, and the `≈` +stays. A creep rate, an elongation in millimetres over a time in hours: + +```cpp +// A creep rate: a length over a time, which borrows neither one's unit. +inline constexpr auto creepRate = var / var; +``` + +```text +1. dl = 2.4 mm +2. t_h = 0.75 h +3. #1 / #2 = ≈0.0000009 m/s +``` + +The rate, 0.00000088... m/s, reads `≈0.0000009`, not `≈0`. A price worked out +in euros per kilowatt-hour is stated in euros per joule: 3401/33480000000 reads +`≈0.0000001`. A value in a unit someone declared keeps that unit's places, +whatever they round to: + +```text +the creep rate in its declared millimetres per minute: ≈0.05 mm/min +``` + +The dish's mass, the mean of three weighings in grams, is computed in grams: ```cpp // The mean of three weighings: their sum times a typed 1/3, which has no exact decimal. @@ -196,27 +246,19 @@ Its trace, rendered in the rounded and padded style: 1. t = 4.21 g; 4.23 g; 4.26 g 2. sum(#1) = 12.7 g 3. 1/3 -4. #2 * #3 = ≈0.004 +4. #2 * #3 = ≈4.2 g ``` A series' sum keeps its quantity's unit: line 2 is in grams. The product on -line 4 is not: it is 0.004233... kg, rounded to 3 places of a kilogram, and -not padded: `≈0.004`. Line 1's weighings keep their second decimal, though the -gram declares one: padding never cuts a decimal short. The `≈` says line 4 was -rounded; the result itself, read in the unit its quantity declares, keeps what -matters: +line 4 is the sum scaled by a pure number, so it is in grams too, rounded at the +gram's one decimal: `≈4.2`. Line 1's weighings keep their second decimal, +though the gram declares one: padding never cuts a decimal short. The `≈` says +line 4 was rounded, as the result does, read in the unit its quantity declares: ```text the dish's mass in its declared grams: ≈4.2 g ``` -Where those 3 places would round a value other than zero to `≈0`, which says -nothing of it, they are extended to its first significant digit, up to 18 -places, and the `≈` stays. A price worked out in euros per kilowatt-hour is -stated in euros per joule: 3401/33480000000 reads `≈0.0000001`, not `≈0`. A -value in a unit someone declared keeps that unit's places, whatever they -round to. - ### What no style rounds **A number the author typed is written exactly, whatever the style**: a @@ -249,8 +291,8 @@ contradicts itself -- so a compared value is never shown rounded. ### Values the exact layer cannot hold A square root, a logarithm or an exponential is irrational almost everywhere, -and the exact sums behind a line fitted through 34 readings at three decimals -can already leave the 64-bit integers of `Rational` +and the exact sums behind a line fitted through 28 points, each on a different +denominator, can already leave the 128-bit integers of `Rational` ([numeric headroom](numeric-headroom.md#least-squares-realistic-and-one-stress-control)). The library does not approximate such values. A formula that needs one **declares the precision it is reported at** -- a unit, decimal places and a @@ -365,12 +407,12 @@ formula, padded style: m_d - 24 g trace, padded style: 1. m_d = 144.0 g 2. 24.0 g -3. #1 - #2 = 0.12 +3. #1 - #2 = 120.0 g ``` The formula states the 24 its author typed; the trace pads it to the gram's one -decimal, as it pads every value in grams. Line 3, 0.12 kg in a unit nobody -declared, is not padded to that unit's default 3 decimals. +decimal, as it pads every value in grams -- line 3 too, a difference of two +values in grams, and so in grams itself. The style reaches every node through the vocabulary, the one argument every `render_node` already receives -- your own included @@ -592,8 +634,8 @@ before the call stack of the evaluation: ``` test\negative\format_places_without_mode.cpp(17): error C7595: 'std::basic_format_string::basic_format_string': call to immediate function is not a constant expression -include\formula-cpp/format.hpp(344): note: failure was caused by call of undefined function or one not declared 'constexpr' -include\formula-cpp/format.hpp(344): note: see usage of 'formula::detail::formula_number_format_needs_a_rounding_mode' +include\formula-cpp/format.hpp(347): note: failure was caused by call of undefined function or one not declared 'constexpr' +include\formula-cpp/format.hpp(347): note: see usage of 'formula::detail::formula_number_format_needs_a_rounding_mode' ``` clang and g++ name the same function, in their own words. @@ -626,15 +668,15 @@ see it: `{:~Mode}` on a `Measured` whose unit declares negative decimals -- rounding to tens or thousands. A value with an exact decimal of at most 18 places is written as it is and never rounded: 1/10^18 at -3 decimals is `0.000000000000000001`. Any other value is rounded through exact arithmetic, -which overflows for one with a large denominator, such as -`Rational::from_double_exact(0.1)` at -3 decimals. `std::format` then throws -`std::format_error` too, starting `formula: this number cannot be spelled as -the format asks`; it never writes a text that is neither the value nor the -rounding the spec asked for. No spec rounds such a value to tens or -thousands. Write `{:~.0HalfEven}` instead to round it to whole units -- a -rounding to 0 to 18 places is spelled by long division, which cannot -overflow, so `from_double_exact(0.1)` reads `≈0` -- or `{:/}` for its exact -fraction, or catch the `std::format_error`. +which overflows for one with a large denominator, such as 2^-120, +`Rational { 1, Rational::Int { 1 } << 120 }`, at -3 decimals. `std::format` +then throws `std::format_error` too, starting `formula: this number cannot be +spelled as the format asks`; it never writes a text that is neither the +value nor the rounding the spec asked for. No spec rounds such a value to +tens or thousands. Write `{:~.0HalfEven}` instead to round it to whole units +-- a rounding to 0 to 18 places is spelled by long division, which cannot +overflow, so 2^-120 reads `≈0` -- or `{:/}` for its exact fraction, or catch +the `std::format_error`. ## Formatting outcomes, units, dimensions and enumerations diff --git a/docs/expressions.md b/docs/expressions.md index cf8aae5e..bdd87033 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -412,11 +412,11 @@ integers, as `linear_least_squares` does There is one further refusal in the same function, for a different reason. `checked_exact_nth_root` rejects the most negative representable numerator -(`IntMin`) with `ArithmeticError::Overflow` rather than `Inexact`: `IntMin`'s -cube root exists and is exactly representable, but negating `IntMin` to reach -a positive intermediate is signed overflow, undefined behaviour, before the -root is ever taken. `Overflow` names what actually goes wrong; treating it as -`Inexact` would blame the wrong layer. +(`Rational::Int`'s minimum, -2^127) with `ArithmeticError::Overflow` rather +than `Inexact`: its 127th root, -2, exists and is exactly representable, but +negating it to reach a positive intermediate is signed overflow, undefined +behaviour, before the root is ever taken. `Overflow` names what actually goes +wrong; treating it as `Inexact` would blame the wrong layer. A root of degree zero names no operation at all and is refused at compile time, the same way a dimensional mismatch is, by the library's own @@ -508,8 +508,15 @@ CHECK(lnAt(Rational { 2 }) == Ratio whose `lnAt` helper evaluates `rounded_ln` at the given ratio.) The places are the method's own, and at most 18; the result must fit a -`Rational` there, which at 18 places means a magnitude below about 9.2, so -the `log10` of a count near 10^18 is reported at 17. Only ln 1, log10 10^k and +`Rational` there, which any logarithm does: the `log10` of 10^18 - 1 is +reported to all 18 places. The integer kernel takes an argument whose +numerator and denominator each fit 64 bits, the range it was built for: a +wider argument, which a `Rational` can hold, is `Overflow`, as `log10` of +2^70 is, and so is `exp` of more than 44. Two kinds of wide argument never +reach the kernel: a power of ten, 10^19 up to 10^38 or one over it, is +answered exactly, so `log10` of 10^30 is 30; and `exp` of less than -43 is +0, or one unit under `Ceiling` and `AwayFromZero`, whatever its width. +Only ln 1, log10 10^k and exp 0 can tie, and the mode breaks the tie as `rounded<>` does: `log10` of 10^15 at -1 places is 20, 10 or 20 under `HalfAwayFromZero`, `HalfTowardZero` and `HalfEven`. A rounding the computation cannot decide -- @@ -600,15 +607,15 @@ parenthesis is needed to preserve the meaning: 3. V_c = 300 l 4. #2 / #3 = 3/5 5. #4 = 3/5 [Water/cement ratio, Example Standard 1:2020, 5.4.2, (3)] -6. #1 * #5 = 150 -7. #6 = 150 [Cost of a mix at a given water/cement ratio, Example Standard 9:2021, 2.1] +6. #1 * #5 = 150 EUR +7. #6 = 150 EUR [Cost of a mix at a given water/cement ratio, Example Standard 9:2021, 2.1] ``` Step 5 is the reused formula, carrying its own citation; step 6 consumes it. `c_u` is a price in euros, a dimension of its own rather than a bare number ([Base dimensions the SI does not have](dimensions.md#base-dimensions-the-si-does-not-have)), -so the cost is in euros too; steps 6 and 7 show no unit only because a -computed step carries no unit symbol of its own +so the cost is in euros too: step 6 scales the price by a pure number, the +ratio, and so reads in the price's unit, and step 7 restates step 6 ([Tracing](tracing.md#reading-a-derivation)). One asymmetry is worth knowing before you rely on it. Using the same diff --git a/docs/gallery.md b/docs/gallery.md index 165e34f0..55e942ab 100644 --- a/docs/gallery.md +++ b/docs/gallery.md @@ -299,8 +299,8 @@ m / V ``` 1. m = 1200 kg 2. V = 1/2 m3 -3. #1 / #2 = 2400 -4. #3 = 2400 [Bulk density of a compacted specimen, Example Standard 1:2020, 4.2, (3)] +3. #1 / #2 = 2400 kg/m^3 +4. #3 = 2400 kg/m^3 [Bulk density of a compacted specimen, Example Standard 1:2020, 4.2, (3)] ``` ## Worked derivation: compaction-adjusted bulk density @@ -316,9 +316,9 @@ if rho_m < 1737 kg/m3 then rho_m * 1127/1000 else rho_m 2. 1737 kg/m3 3. rho_m = 1523 kg/m3 4. 1127/1000 -5. #3 * #4 = 1716421/1000 -6. if #1 < #2 then #5 = 1716421/1000 -7. #6 = 1716421/1000 [Compaction-adjusted bulk density, Example Standard 5:2020, 4.5] +5. #3 * #4 = 1716421/1000 kg/m3 +6. if #1 < #2 then #5 = 1716421/1000 kg/m3 +7. #6 = 1716421/1000 kg/m3 [Compaction-adjusted bulk density, Example Standard 5:2020, 4.5] ``` ## Worked derivation: maximum specimen diameter, alongside the circular area it validates @@ -351,13 +351,13 @@ pi * d^2 / 4 1. f = 33 MPa 2. d = 127 mm 3. lookup(#2) = 113/100 MPa [103 to under 163 mm] -4. #1 - #3 = 31870000 +4. #1 - #3 = 3187/100 MPa 5. lookup(key Cylinder) = 863/1000 -6. #4 * #5 = 27503810 +6. #4 * #5 = 2750381/100000 MPa 7. t = 57 h 8. interpolate(#7) = 147/200 [between 31 and 83 h] -9. #6 * #8 = 404306007/20 -10. #9 = 404306007/20 [Size- and age-corrected crushing strength, Example Standard 7:2020, 8.5, (8)] +9. #6 * #8 = 404306007/20000000 MPa +10. #9 = 404306007/20000000 MPa [Size- and age-corrected crushing strength, Example Standard 7:2020, 8.5, (8)] ``` ## Worked derivation: a lookup that found nothing @@ -385,10 +385,10 @@ k_s * F / a^2 ``` 1. k_s = 1043/1000 2. F = 226000 N -3. #1 * #2 = 235718 +3. #1 * #2 = 235718 N 4. a = 150 mm -5. #4^2 = 9/400 -6. #3 / #5 = 94287200/9 +5. #4^2 = 9/400 m^2 +6. #3 / #5 = 94287200/9 kg/(m s^2) 7. round(#6, in MPa) = 21/2 MPa [rounded to 1 dp (method default); nearest, ties away from zero] 8. #7 = 21/2 MPa [variant Cube (1st of 2), selected by tag] ``` @@ -402,10 +402,10 @@ k_s * F / a^2 ``` 1. k_s = 887/1000 [fixed by jurisdiction overlay: Shape factor, Example Standard 7:2020 NA, NA.2] 2. F = 226000 N -3. #1 * #2 = 200462 +3. #1 * #2 = 200462 N 4. a = 150 mm -5. #4^2 = 9/400 -6. #3 / #5 = 80184800/9 +5. #4^2 = 9/400 m^2 +6. #3 / #5 = 80184800/9 kg/(m s^2) 7. round(#6, in N/mm2) = 891/100 N/mm2 [rounded to 2 dp (jurisdiction overlay: Example Standard 7:2020 NA, NA.4); nearest, ties away from zero] 8. #7 = 891/100 N/mm2 [variant Cube (1st of 2), selected by tag] ``` @@ -452,7 +452,7 @@ And by the overlay's two checks in its place. The overlay lists the shape factor ## Worked derivation: a grading curve read between two screens -The same percentages paired with the declared screens as a curve, and read at 173 m. The last step names the two screens the answer lay between. Its value, like every computed step's, reads in the coherent unit, a plain fraction for a percentage: 6927/10625 is about 65.2 %. +The same percentages paired with the declared screens as a curve, and read at 173 m. The last step names the two screens the answer lay between. Its value is read off computed percentages -- 100 % less a ratio of two masses, in two units with no one unit to borrow -- so it reads in the coherent unit, a plain fraction: 6927/10625 is about 65.2 %. ``` interpolate(curve(domain(103, 127, 163, 197, 241 m), 100 % - cumulative(m_r(i), from last) / m_t), at 173 m) @@ -517,7 +517,7 @@ round(sqrt(sample_variance(m(i))), to 2 dp of g) ``` 1. m = 201/5 g; 199/5 g; 81/2 g; 44 g; 40 g; 433/10 g -2. sample_variance(#1) = 427/125000000 +2. sample_variance(#1) = 427/125000000 kg^2 3. round(sqrt(#2), to 2 dp of g) = 37/20 g [nearest, ties away from zero] 4. #3 = 37/20 g [Spread of repeated determinations, Example Standard 5:2022, 7.2] ``` @@ -532,17 +532,17 @@ sample_mean(without outliers(m(i); abs(x - pass mean) > 3/50 * pass mean; most e 1. m = 201/5 g; 199/5 g; 81/2 g; 44 g; 40 g; 433/10 g 2. 3/50 3. pass mean = 413/10 g -4. #2 * #3 = 1239/500000 +4. #2 * #3 = 1239/500 g 5. pass 1: 6 values, mean 413/10 g 6. rejected element 4 of 6 (44 g) in pass 1: abs(x - mean) = 27/10 g > 1239/500 g (deviation from mean) 7. 3/50 8. pass mean = 1019/25 g -9. #7 * #8 = 3057/1250000 +9. #7 * #8 = 3057/1250 g 10. pass 2: 5 values, mean 1019/25 g 11. rejected element 6 of 6 (433/10 g) in pass 2: abs(x - mean) = 127/50 g > 3057/1250 g (deviation from mean) 12. 3/50 13. pass mean = 321/8 g -14. #12 * #13 = 963/400000 +14. #12 * #13 = 963/400 g 15. pass 3: 4 values, mean 321/8 g 16. settled: 2 rejected, 4 remain 17. sample_mean(#16) = 321/8 g @@ -559,12 +559,12 @@ sample_mean(without outliers(m(i); abs(x - pass mean) > 3/50 * pass mean; most e 1. m = 201/5 g; 199/5 g; 81/2 g; 44 g; 40 g; 433/10 g 2. 3/50 3. pass mean = 413/10 g -4. #2 * #3 = 1239/500000 +4. #2 * #3 = 1239/500 g 5. pass 1: 6 values, mean 413/10 g 6. rejected element 4 of 6 (44 g) in pass 1: abs(x - mean) = 27/10 g > 1239/500 g (deviation from mean) 7. 3/50 8. pass mean = 1019/25 g -9. #7 * #8 = 3057/1250000 +9. #7 * #8 = 3057/1250 g 10. pass 2: 5 values, mean 1019/25 g 11. element 6 of 6 would be rejection 2 of at most 1: discard the determinations and repeat the test [Outliers, Example Standard 5:2022, 7.4] 12. sample_mean(#11) = argument outside the domain of the operation @@ -581,20 +581,20 @@ require abs(x_A - x_B) <= r(1/10 g + 1/50 * level; level = (x_A + x_B) / 2) ``` 1. x_A = 40 g 2. x_B = 8181/200 g -3. #1 - #2 = -181/200000 -4. abs(#3) = 181/200000 +3. #1 - #2 = -181/200 g +4. abs(#3) = 181/200 g 5. x_A = 40 g 6. x_B = 8181/200 g -7. #5 + #6 = 16181/200000 +7. #5 + #6 = 16181/200 g 8. 2 -9. #7 / #8 = 16181/400000 +9. #7 / #8 = 16181/400 g 10. level (pass 1 of 2) = #9 = 16181/400 g 11. 1/10 g 12. 1/50 13. level = 16181/400 g [bound by #16] -14. #12 * #13 = 16181/20000000 -15. #11 + #14 = 18181/20000000 -16. r at level #10 (pass 2 of 2) = #15 = 18181/20000000 +14. #12 * #13 = 16181/20000 g +15. #11 + #14 = 18181/20000 g +16. r at level #10 (pass 2 of 2) = #15 = 18181/20000 g 17. require #4 <= #16 [satisfied] ``` @@ -611,9 +611,9 @@ f / ((F / (a * a)) of ReferenceSpecimen) 2. F = 579630 N, from record ReferenceSpecimen (sample 23, test 3) 3. a = 139 mm, from record ReferenceSpecimen (sample 23, test 3) 4. a = 139 mm, from record ReferenceSpecimen (sample 23, test 3) -5. #3 * #4 = 19321/1000000 -6. #2 / #5 = 30000000 -7. #6 from record ReferenceSpecimen (sample 23, test 3) = 30000000 +5. #3 * #4 = 19321/1000000 m^2 +6. #2 / #5 = 30000000 kg/(m s^2) +7. #6 from record ReferenceSpecimen (sample 23, test 3) = 30000000 kg/(m s^2) 8. #1 / #7 = 6/5 ``` @@ -659,41 +659,41 @@ up to 4 attempts: w(k) = 152/25 g + w(k-1) / 2, starting from w(0) = 0 g; accept 2. 152/25 g 3. w(k-1) = 0 g 4. 2 -5. #3 / #4 = 0 -6. #2 + #5 = 19/3125 +5. #3 / #4 = 0 g +6. #2 + #5 = 152/25 g 7. w(k-1) = 0 g 8. w(k) = 152/25 g -9. #7 - #8 = -19/3125 +9. #7 - #8 = -152/25 g 10. -19/25 g 11. attempt 1: w(k) = #6 = 152/25 g; judged #9 >= #10: rejected 12. 152/25 g 13. w(k-1) = 152/25 g 14. 2 -15. #13 / #14 = 19/6250 -16. #12 + #15 = 57/6250 +15. #13 / #14 = 76/25 g +16. #12 + #15 = 228/25 g 17. w(k-1) = 152/25 g 18. w(k) = 228/25 g -19. #17 - #18 = -19/6250 +19. #17 - #18 = -76/25 g 20. -19/25 g 21. attempt 2: w(k) = #16 = 228/25 g; judged #19 >= #20: rejected 22. 152/25 g 23. w(k-1) = 228/25 g 24. 2 -25. #23 / #24 = 57/12500 -26. #22 + #25 = 133/12500 +25. #23 / #24 = 114/25 g +26. #22 + #25 = 266/25 g 27. w(k-1) = 228/25 g 28. w(k) = 266/25 g -29. #27 - #28 = -19/12500 +29. #27 - #28 = -38/25 g 30. -19/25 g 31. attempt 3: w(k) = #26 = 266/25 g; judged #29 >= #30: rejected 32. 152/25 g 33. w(k-1) = 266/25 g 34. 2 -35. #33 / #34 = 133/25000 -36. #32 + #35 = 57/5000 +35. #33 / #34 = 133/25 g +36. #32 + #35 = 57/5 g 37. w(k-1) = 266/25 g 38. w(k) = 57/5 g -39. #37 - #38 = -19/25000 +39. #37 - #38 = -19/25 g 40. -19/25 g 41. attempt 4: w(k) = #36 = 57/5 g; judged #39 >= #40: accepted 42. w = retry: accepted at attempt 4 of 4 = 57/5 g [Settled estimate, Example Standard 12, 6] diff --git a/docs/lookup-tables.md b/docs/lookup-tables.md index 14a6a749..3499c043 100644 --- a/docs/lookup-tables.md +++ b/docs/lookup-tables.md @@ -87,6 +87,12 @@ plain integers is, and so is a `std::array` of them — which is what makes it possible to validate a whole table with `static_assert` rather than only when it happens to be loaded at run time. +A bound whose numerator or denominator does not fit those 64 bits is refused, +never truncated: in a constant expression the table fails to compile, naming +`formula_band_bound_out_of_range`, and reached at run time the program ends, +because a `Band` is built to be a template argument and has no way to carry a +failure. + The node renders as one field per row, in the table's own declared order: ``` @@ -186,11 +192,11 @@ compiler), with the rest of the instantiation backtrace below these lines: ``` In file included from test\negative\lookup_band_gap.cpp:10: -In file included from include\formula-cpp/lookup.hpp:474: -include\formula-cpp/band.hpp(257,19): error: static assertion failed due to requirement 'bands_are_adjacent(formula::Band{103, 1, 197, 1}, formula::Band{241, 1, 331, 1})': formula: this band table has a gap or overlap between two adjacent bands; the earlier band's declared high bound and the later band's declared low bound do not match exactly, and the two offending Band values appear in this diagnostic as the template arguments First and Second of RequireBandsAdjacent - 257 | static_assert(bands_are_adjacent(First, Second), +In file included from include\formula-cpp/lookup.hpp:473: +include\formula-cpp/band.hpp(280,19): error: static assertion failed due to requirement 'bands_are_adjacent(formula::Band{103, 1, 197, 1}, formula::Band{241, 1, 331, 1})': formula: this band table has a gap or overlap between two adjacent bands; the earlier band's declared high bound and the later band's declared low bound do not match exactly, and the two offending Band values appear in this diagnostic as the template arguments First and Second of RequireBandsAdjacent + 280 | static_assert(bands_are_adjacent(First, Second), | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -include\formula-cpp/band.hpp(298,29): note: in instantiation of template class 'formula::RequireBandsAdjacent' requested here +include\formula-cpp/band.hpp(321,29): note: in instantiation of template class 'formula::RequireBandsAdjacent' requested here ``` The message names **both offending rows**, as the values you typed: the one @@ -520,7 +526,11 @@ inline constexpr formula::BreakpointTable<3> SizeCurve { }; ``` -so it renders as the points it is, with `at` rather than any interval wording: +`breakpoint(key)` keeps its key as an `int64` numerator and denominator, as +`band` keeps its bounds, and refuses a key that does not fit them the same way: +it fails to compile in a constant expression, naming +`formula_breakpoint_key_out_of_range`, and ends the program at run time. The +table renders as the points it is, with `at` rather than any interval wording: ``` interpolating: interpolate(d, at 127 mm gives 913/10 %, at 173 mm gives 1051/10 %, at 211 mm gives 1127/10 %) @@ -714,10 +724,10 @@ interpolation drew on: 1. f_m = 40 MPa 2. d = 139 mm 3. lookup(#2) = 1051/10 % [127 to under 173 mm] -4. #1 * #3 = 42040000 +4. #1 * #3 = 1051/25 MPa 5. lookup(key Cylinder) = 863/10 % -6. #4 * #5 = 36280520 -7. #6 = 36280520 [Corrected compressive strength, Example Standard 8:2020, 7.3, (5)] +6. #4 * #5 = 907013/25000 MPa +7. #6 = 907013/25000 MPa [Corrected compressive strength, Example Standard 8:2020, 7.3, (5)] ``` The exact lookup on line 5 adds no such clause, and that is right: its key is @@ -746,9 +756,9 @@ being included or excluded, so it needs neither a band's `to under` nor a curve's closed `to`. Both lookup steps report in the unit their own table is stated in — `1051/10 %`, -`863/10 %` — while lines 4 and 6 report the products in the coherent unit, because an -intermediate that no quantity declares a unit for has none to be shown in. That -is ordinary trace behaviour rather than anything to do with tables; see +`863/10 %` — and lines 4 and 6 report the products in megapascals: each scales +a strength by a percentage, a pure number, and so reads in the strength's unit. +That is ordinary trace behaviour rather than anything to do with tables; see [Tracing and audit trails](tracing.md). **On a miss, that clause is what keeps the line from lying:** diff --git a/docs/methods-and-overlays.md b/docs/methods-and-overlays.md index 7c26c5b5..20e309ad 100644 --- a/docs/methods-and-overlays.md +++ b/docs/methods-and-overlays.md @@ -95,15 +95,20 @@ the rounded variant beneath it. The rounding step says whose rule it was: ```text 1. k_s = 1043/1000 2. F = 89300 N -3. #1 * #2 = 931399/10 +3. #1 * #2 = 931399/10 N 4. a = 163 mm 5. b = 103 mm -6. #4 * #5 = 16789/1000000 -7. #3 / #6 = 93139900000/16789 +6. #4 * #5 = 16789/1000000 m^2 +7. #3 / #6 = 93139900000/16789 kg/(m s^2) 8. round(#7, in MPa) = 11/2 MPa [rounded to 1 dp (method default); nearest, ties away from zero] 9. #8 = 11/2 MPa [variant Cube (2nd of 3), selected by tag] ``` +Each computed step reads in a unit written after it. The force scaled by the +shape factor, a pure number, keeps the force's newtons. The area and the +stress borrow neither operand's unit, so each reads in the coherent unit of its +dimension, spelt from the base units: `m^2`, and `kg/(m s^2)` for the pascal. + ### Naming a variant as the published method does A tag is shown under its own name, `Cube`. Where the published method words a @@ -270,11 +275,11 @@ north cube: 4590000 Pa 1. k_s = 863/1000 [fixed by jurisdiction overlay: Shape factor, Example Standard 12:2021 NA, NA.2.1] 2. F = 89300 N -3. #1 * #2 = 770659/10 +3. #1 * #2 = 770659/10 N 4. a = 163 mm 5. b = 103 mm -6. #4 * #5 = 16789/1000000 -7. #3 / #6 = 77065900000/16789 +6. #4 * #5 = 16789/1000000 m^2 +7. #3 / #6 = 77065900000/16789 kg/(m s^2) 8. round(#7, in N/mm2) = 459/100 N/mm2 [rounded to 2 dp (jurisdiction overlay: Example Standard 12:2021 NA, NA.4); nearest, ties away from zero] 9. #8 = 459/100 N/mm2 [variant Cube (2nd of 3), selected by tag] ``` @@ -318,11 +323,11 @@ south cube: 3400000 Pa 3. #1 / #2 = 103/163 4. k_s = #3 = 103/163 [derived by jurisdiction overlay: Example Standard 7:2019 A, A.3] 5. F = 89300 N -6. #4 * #5 = 9197900/163 +6. #4 * #5 = 9197900/163 N 7. a = 163 mm 8. b = 103 mm -9. #7 * #8 = 16789/1000000 -10. #6 / #9 = 89300000000/26569 +9. #7 * #8 = 16789/1000000 m^2 +10. #6 / #9 = 89300000000/26569 kg/(m s^2) 11. round(#10, in MPa) = 17/5 MPa [rounded to 1 dp (method default); nearest, ties away from zero] 12. #11 = 17/5 MPa [variant Cube (2nd of 3), selected by tag; 1 of 3 pruned by jurisdiction overlay: Example Standard 7:2019 A, A.1] ``` @@ -338,10 +343,10 @@ the position is the published one, not the Cylinder's place in what is left: 1. F = 89300 N 2. 1127/1000 3. d = 135 mm -4. #3^2 = 729/40000 -5. #2 * #4 = 821583/40000000 -6. #1 / #5 = 3572000000000/821583 -7. #6 = 3572000000000/821583 [replaced by jurisdiction overlay: Example Standard 7:2019 A, A.5] +4. #3^2 = 729/40000 m^2 +5. #2 * #4 = 821583/40000000 m^2 +6. #1 / #5 = 3572000000000/821583 kg/(m s^2) +7. #6 = 3572000000000/821583 kg/(m s^2) [replaced by jurisdiction overlay: Example Standard 7:2019 A, A.5] 8. round(#7, in MPa) = 43/10 MPa [rounded to 1 dp (method default); nearest, ties away from zero] 9. #8 = 43/10 MPa [variant cylinder 135 x 271 mm (3rd of 3), selected by tag; 1 of 3 pruned by jurisdiction overlay: Example Standard 7:2019 A, A.1] ``` @@ -583,16 +588,16 @@ them: 4. a = 163 mm 5. 173/100 6. b = 103 mm -7. #5 * #6 = 17819/100000 +7. #5 * #6 = 17819/100 mm 8. require #4 <= #7 [satisfied; jurisdiction overlay: Acceptance, Example Standard 9:2022 B, B.2] 9. acceptance(#3, #8) [jurisdiction overlay: Acceptance, Example Standard 9:2022 B, B.2] ``` A method with no constraints still gets its `acceptance` line -- `acceptance(none)`, with whose it is -- so a jurisdiction that removed every -check is never silent about it. Step 7 is `1.73 x 103 mm` in coherent SI, 0.17819 m, -written without its unit, as the computed steps of the cube's trace above are -too. +check is never silent about it. Step 7 is `1.73 x 103 mm`, a length scaled by a +pure number, so it reads in the length's millimetres, `17819/100 mm`, as the +force scaled by the shape factor in the cube's trace above reads in newtons. The constraints carry whose they are with them: `with_constraints` puts an `OverlaidConstraints` in the method -- the jurisdiction's set together with diff --git a/docs/numbers.md b/docs/numbers.md index f145f56c..7d7e9b61 100644 --- a/docs/numbers.md +++ b/docs/numbers.md @@ -116,8 +116,8 @@ 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` | more significant digits than `Rational`'s 64-bit numerator holds | `formula_rational_literal_out_of_range` | -| `0.0000000000000000001_r` | a denominator of 10^19 does not fit either | `formula_rational_literal_out_of_range` | +| `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` | | `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` | @@ -249,16 +249,19 @@ the answer. ## Limits -`Rational`'s numerator and denominator are `std::int64_t`. `DecimalPlaces` -and the decimal-place form of `round` are limited to ±18 places, because the -scale factor `10^places` must itself fit in `Int`; an out-of-range +`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 `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 -either one. That ±18 ceiling is rarely the one actually hit, though. +either one. Rounding to `N` decimal places scales the value by `10^N`. Common factors of -two cancel against the denominator first, so what must fit in `Int` is +two cancel against the denominator first, so what must fit in `Rational::Int` +is ``` |numerator| * (10^N / gcd(10^N, denominator)) @@ -266,31 +269,24 @@ two cancel against the denominator first, so what must fit in `Int` is which for a power-of-two denominator is `|numerator| * 5^N`. **The limit is set by the numerator's magnitude**, not by the denominator and not by the size of -the value: - -| numerator (over `2^54`) | max decimal places | -|---|---| -| `1` | 18 | -| `10^9` | 18 | -| `10^12` | 15 | -| `8106479329266893` (a `double`'s mantissa) | 4 | - -Holding the numerator at 53 bits and varying the denominator from `2^10` to -`2^62` leaves the answer at 4 places throughout; `1 / 2^60` rounds correctly at -all 18. - -This is why `from_decimal` and `rational_from_double` behave so differently for -the same nominal value. `from_decimal(45, -2)` is `9/20` -- numerator 9, so all -18 places work. The same 0,45 as a `double` is exactly -`8106479329266893 / 2^54`: a `double`'s mantissa is always about 53 bits -whatever its exponent, so **any** value from `from_double_exact` or -`rational_from_double` caps out at 4 decimal places, large or small alike. -Asking for more reports `Overflow`, never a wrong number. - -`from_double_exact` additionally refuses a `double` whose exact value would need -a denominator above `2^63`. Measured, that rules out a full-mantissa value below -`2^-10` (about 0,00098): `0.0009765625` converts, `0.0001` is refused outright, -before rounding is even reached. +the value: `1 / 2^121` rounds correctly at all 18 places, while a 100-bit +numerator over the same denominator is refused at 18. + +A `double` below 2^53 in magnitude has a numerator of at most 53 bits, and +rounding it at up to 18 places forms at most 2^53 · 5^18 · 2^18, below 2^113, +so such a value from `from_double_exact` or `rational_from_double` rounds at +every place from 0 to 18: 0,45 as a `double` is exactly +`8106479329266893 / 2^54`, and rounds to 18 places as 0.450000000000000011. +Past that the limit returns: a whole `double` such as 1e21 has a numerator of +its own magnitude and nothing to cancel, so it is refused at 18 places, 1e38 +even at 1, and `2^-100` at -18 places multiplies its denominator past 2^127. +`from_decimal(45, -2)` is `9/20` -- the same nominal value, and what a method +that writes 0,45 means. + +`from_double_exact` refuses a `double` whose exact value would need a +denominator of `2^127` or more, before rounding is even reached. That limit is +set by the value's magnitude: `0.0001` converts, over `2^66`, while `1e-30`, +over `2^147`, is refused. For an exact decimal, prefer `from_decimal`: its numerator is whatever you passed -- usually a handful of significant digits -- so the limit above does not @@ -302,21 +298,18 @@ rounding to decimals. Rounding to a *negative* number of places -- to whole tens, hundreds, thousands -- scales the other way: the step is an integer, so it multiplies the denominator rather than the numerator, and there the denominator is what -constrains you. `1/10^18` is refused at every negative place for exactly that -reason, while `1/3` handles them all. +constrains you. Reach for `rational_from_double` only when the input is a genuinely measured -`double`, and only at modest decimal precision. +`double`. Overflow is always reported, never absorbed -- with one nuance worth knowing. `checked_add` and `checked_sub` can report overflow for a result that would, -once reduced, actually fit: if both operands' numerators are near `2^63` and +once reduced, actually fit: if both operands' numerators are near `2^127` and their denominators share a large common factor, the intermediate numerator -sum can exceed `int64_t` even though the reduced answer is representable -- -for example `IntMax/3037000500 + IntMax/3037000500`, whose exact value -`IntMax/1518500250` fits easily. Multiplication does not have this problem, -because it cross-reduces before multiplying. Measured over 473,984 operand -pairs against 128-bit ground truth: no wrong values were ever produced, and -no false overflows occurred at all for numerators below roughly 10^6, which -covers realistic use. The failure direction is always the safe one -- a -reported error, never a wrong number. +sum can exceed `Rational::Int` even though the reduced answer is +representable -- for example `IntMax/3037000500 + IntMax/3037000500`, with +`IntMax` the largest `Rational::Int`, whose exact value `IntMax/1518500250` +fits easily. Multiplication does not have this problem, because it +cross-reduces before multiplying. The failure direction is always the safe +one -- a reported error, never a wrong number. diff --git a/docs/numeric-headroom.md b/docs/numeric-headroom.md index 77582c33..d0ab03cb 100644 --- a/docs/numeric-headroom.md +++ b/docs/numeric-headroom.md @@ -1,10 +1,10 @@ # Numeric headroom `formula-cpp` computes exactly: every value is a `Rational`, a fraction whose -numerator and denominator are 64-bit signed integers. A result that does not -fit is never wrapped or rounded away; it is refused as +numerator and denominator are signed 128-bit integers (`formula::Int128`). A +result that does not fit is never wrapped or rounded away; it is refused as `ArithmeticError::Overflow`. This page answers one question with -measurements: **how much of those 64 bits do real formulas use, and is that +measurements: **how much of those 128 bits do real formulas use, and is that enough?** **Every figure in the tables below is generated.** Each table is what the @@ -15,66 +15,72 @@ a table by hand. ## The answer -**Not for every realistic case.** Most formulas leave a wide margin, 30 bits -or more. Four kinds of realistic formula do not, and the tables below give -their figures: - -- the **sample variance** of masses near 40 g read to 5 or 6 decimal places - of a gram, which at 6 places overflows on a large share of samples; -- **rejecting outliers by standard deviations** from such a sample, which - forms limit² × s² on top and runs out sooner; -- a **cylinder's compressive strength**, 4F / (π d²), which overflows at many - ordinary diameters -- 139 mm among them, while 135 mm fits, with only 5 - bits to spare in the methods example; -- a **least-squares line** through readings at 3 decimal places, which - overflows from 34 points, though not at every size above. - -A line through realistic observations reported at declared decimals answers -at every size measured below; its exact route stops sooner, at 29 points on -readings at 3 decimal places, where the curve fit's stops at 34. A different -denominator on every point outgrows the rounded route too, from 62 points. - -The project's decision rule is: **any realistic case under 8 bits of headroom -recommends wider intermediates**: 128-bit intermediate arithmetic, computing -each product and sum in 128 bits before reducing. These cases are under it, -so the census recommends 128-bit intermediate arithmetic. - -**Whether 128-bit intermediate arithmetic is enough depends on where the last -conversion happens.** Wider intermediates help only when every value that is -stored fits 64 bits. The evaluator works in the coherent unit and converts to the -result's declared unit last. For most of the variances that overflow at 6 -decimal places, and for every cylinder strength that overflows, the exact -value *in SI* (kg², Pa) needs 64 bits or more; in the declared unit (g², MPa) -every one fits, in at most 45 bits (the exact sizes below). So 128-bit -intermediate arithmetic is enough only if the SI value is never stored: -every node's `Evaluated`, and every trace step's -value, is the SI number, so the variance node's own result would have to be -computed and recorded in the declared unit or a scaled one -- a change to the -unit a node computes and records in, not only to the last conversion. -Otherwise these cases need a wider stored representation: a fixed-width -wide-integer `Rational` offered as a `Rep`. Which of these to build is a -design decision, tracked in -[issue #1](https://github.com/LASTRADA-Software/formula-cpp/issues/1); this -page does not make it. - -**Headroom** here is `63` minus the bits used by the largest integer an +**Yes, for every realistic case measured.** The four kinds of realistic +formula that come closest all answer, at every input measured, with far more +than 8 bits to spare; the tables below give their figures: + +- the **sample variance** of six masses near 40 g read to 6 decimal places of + a gram: none of 1000 samples overflows, and the least headroom any leaves is + 62 bits; +- **rejecting outliers by 7/4 standard deviations** from such a sample, which + forms limit² × s² on top: none of 1000 overflows, with at least 58 bits left; +- a **cylinder's compressive strength**, 4F / (π d²), at 89.3 kN: no diameter + from 101 to 163 mm overflows, 139 mm among them, with at least 63 bits left; +- a **least-squares line** through readings at 3 decimal places: no size from + 2 to 128 points overflows, with at least 54 bits left. + +What still overflows is a stress control, built to do so: a different +denominator on every point outgrows a line's exact sums from 28 points, and +the wider integers of its rounded route from 58. + +The project's decision rule stays: **any realistic case under 8 bits of +headroom recommends wider arithmetic.** No realistic formula measured here is +under it; the one program the examples table shows under it, `opaque_and_retry`, +fits a stress control there on purpose (below). The rule is what a change that +quietly spends headroom is judged by. + +**Headroom** here is `127` minus the bits used by the largest integer an evaluation formed -- numerators, denominators *and* the intermediates between -them: a sign bit aside, a 64-bit signed integer holds 63 bits, and a cross +them: a sign bit aside, a 128-bit signed integer holds 127 bits, and a cross term that only just fits is as close to overflowing as a numerator that only just fits. So it can read lower than a count of the result's numerator and denominator alone. 0 bits of headroom means the evaluation came within a factor of 2 of overflowing. +## What was chosen, and why + +At 64 bits, the variance and the rejection at fine resolution, the cylinder's +strength and the least-squares line were all under the 8-bit line, and many +of their evaluations overflowed. Three findings decided the remedy: + +- **128-bit intermediates alone could not have been enough.** `checked_mul` + reduces across its operands before it multiplies, so its product is already + in lowest terms: a product that overflows is a result that overflows. Only a + sum can overflow before it reduces. +- **The values are stored in SI.** Every node's `Evaluated`, and + every trace step's value, is in the coherent unit, and there the widest + exact variance in kg² needs 65 bits and the widest strength in Pa 64, though + in the declared g² and MPa they need at most 45 and 44 (the exact sizes + below). Storing them in the declared unit would change the evaluator's rule + that every leaf is converted to SI, and a variance node has no declared unit + to work in. +- **So the stored integer was widened.** `Rational` stores its numerator and + denominator in `formula::Int128`, 128 bits: the compiler's own 128-bit + integer computes where it has one (GCC, Clang), and portable `constexpr` + code everywhere else (cl, clang-cl). A computation that answered at 64 bits + gives the same answer; some that were refused with `Overflow` now answer. + ## Why a fraction's integers grow Adding fractions puts them over a common denominator. A mass of 40.053270 g is 4005327/100000 g, in kilograms 4005327/100000000. Squaring a deviation squares the denominator; summing six squared deviations whose denominators differ multiplies in each new factor. A variance at microgram resolution -needs denominators near 10^18 before anything is divided, and 10^18 is -already 60 of the 63 bits. The value is small; the integers that hold it -exactly are not. `double` does not have this problem because it gives up -exactness instead, which is exactly what this library exists not to do. +needs denominators near 10^18 before anything is divided, and 10^18 takes +60 bits: nearly all of a 64-bit integer, and under half of a 128-bit one. +The value is small; the integers that hold it exactly are not. `double` +does not have this problem because it gives up exactness instead, which is +exactly what this library exists not to do. ## How it was measured @@ -95,14 +101,14 @@ tally, which keeps the largest seen in four roles: on the way, such as the cross terms of a sum, and the two scaled magnitudes a decimal-exponent comparison forms (`at_least_pow10`); - **unsigned**: `rounded_sqrt`'s integer square root, which works in unsigned - 64-bit integers and so has 64 bits, not 63. + 128-bit integers and so has 128 bits, not 127. The hooks sit in `detail/checked_int.hpp`'s checked primitives, in `Rational::make`, in `rounding.hpp`'s decimal-exponent comparison and in `rounded_root.hpp`'s unsigned arithmetic. Leaf unit conversion, statistics, the rejection loop and `rounded_sqrt` compute in `Rational` directly rather than through a representation's `RepTraits`, so a wrapping representation -would have missed them. Powers of ten up to 10^18 are formed unhooked, and +would have missed them. Powers of ten up to 10^38 are formed unhooked, and counted only when they reach a product or a fraction. Without the macro every hook expands to nothing, its arguments unevaluated: a release object built with it off disassembles identically to one built before the hooks @@ -120,15 +126,16 @@ an overflow there is still a refused result -- but their headroom is not measured. Nine examples evaluate some of their formulas that way: `constraints`, `dimensions_and_units`, `expressions`, `lookup_tables`, `quantities`, `records`, `rounding_and_conditionals`, `series` and `statistics`. -A row that reads 0 | 0 | 0 and the full 63 bits means the program counted no +A row that reads 0 | 0 | 0 and the full 127 bits means the program counted no integer at run time. For `quantities` that is because it evaluates its formulas at compile time; the one thing it does at run time, combining an absent input, computes no integer, so there is nothing for the census to tally. `expressions` evaluates one formula at run time, and that evaluation returns a value a person entered without computing it, so its row reports no integer either. -The figures are deterministic: the census program prints the same on cl -19.51 and gcc 13.3, and the clang and gcc presets hold it to the same pins. +The figures are deterministic: the census program's own tables print the +same on cl 19.51, clang-cl 22.1, g++ 14.2 and clang++ 20.1, each of which +regenerates this page and compares it, and holds the census to the same pins. The examples table below is cl's. clang and gcc evaluate a `const` local's constant initialiser at compile time, where cl runs it, so under them a program can report fewer integers and leave more headroom. The test holds @@ -148,44 +155,42 @@ Each program's largest integers over everything it evaluates at run time. | program | numerator bits | denominator bits | intermediate bits | headroom | |---|---|---|---|---| -| example `simple` | 4 | 10 | 6 | 53 | -| example `exact_numbers` | 9 | 10 | 9 | 53 | -| example `dimensions_and_units` | 22 | 10 | 22 | 41 | -| example `quantities` | 0 | 0 | 0 | 63 | -| example `expressions` | 0 | 0 | 0 | 63 | -| example `citations` | 4 | 10 | 6 | 53 | -| example `composition` | 10 | 10 | 9 | 53 | -| example `electricity_bill` | 31 | 26 | 31 | 32 | -| example `tracing` | 10 | 10 | 9 | 53 | -| example `rounding_and_conditionals` | 27 | 20 | 27 | 36 | -| example `constraints` | 26 | 20 | 26 | 37 | -| example `lookup_tables` | 26 | 20 | 26 | 37 | -| example `methods_and_overlays` | 58 | 39 | 58 | 5 | -| example `statistics` | 20 | 35 | 35 | 28 | -| example `series` | 15 | 15 | 15 | 48 | -| example `records` | 25 | 25 | 25 | 38 | -| example `opaque_and_retry` | 48 | 60 | 60 | 3 | -| example `display` | 15 | 17 | 17 | 46 | -| the gallery generator | 29 | 27 | 29 | 34 | +| example `simple` | 4 | 10 | 6 | 117 | +| example `exact_numbers` | 9 | 10 | 9 | 117 | +| example `dimensions_and_units` | 22 | 10 | 22 | 105 | +| example `quantities` | 0 | 0 | 0 | 127 | +| example `expressions` | 0 | 0 | 0 | 127 | +| example `citations` | 4 | 10 | 6 | 117 | +| example `composition` | 10 | 10 | 9 | 117 | +| example `electricity_bill` | 31 | 26 | 31 | 96 | +| example `tracing` | 10 | 10 | 9 | 117 | +| example `rounding_and_conditionals` | 27 | 20 | 27 | 100 | +| example `constraints` | 26 | 20 | 26 | 101 | +| example `lookup_tables` | 26 | 20 | 26 | 101 | +| example `methods_and_overlays` | 58 | 39 | 58 | 69 | +| example `statistics` | 20 | 35 | 35 | 92 | +| example `series` | 15 | 15 | 15 | 112 | +| example `records` | 25 | 25 | 25 | 102 | +| example `opaque_and_retry` | 93 | 96 | 121 | 6 | +| example `display` | 20 | 21 | 21 | 106 | +| the gallery generator | 29 | 27 | 29 | 98 | -The lowest is `opaque_and_retry`, at 3 bits, on purpose: it fits fifteen points on +The lowest is `opaque_and_retry`, at 6 bits, on purpose: it fits twenty-seven points on distinct denominators to show a least-squares fit refusing with `Overflow`, and the census counts the integers the fit formed before it was refused (see [Least squares](#least-squares-realistic-and-one-stress-control)). Of the examples that -compute only results, the lowest is `methods_and_overlays`, under the 8-bit line: its cylinder +compute only results, the lowest is `methods_and_overlays`, at 69 bits: its cylinder variant divides a force of 89.3 kN by the library's rational π, 245850922/78256779, times a squared diameter of 135 mm, and a jurisdiction's replacement of that variant divides it by 1127/1000 times the squared -diameter. It is the cylinder strength the tables below take apart, at a -diameter they list as fitting, and it shows how little such a division -leaves. +diameter. It is the cylinder strength the tables below take apart. ### Statistics, rejection and grading curves (realistic) The fixtures are the shared fixtures of the statistics tests: masses of about 40 g read to 0.1 g. The spread is `rounded_sqrt` of the variance; its -unsigned bits are out of 64. The 64-point curve reads invented screen +unsigned bits are out of 128. The 64-point curve reads invented screen openings from 101 to 461 mm. The last two rows are the least-squares fit ([Opaque operations and bounded retry](opaque-and-retry.md)) on its own test fixtures; the fit over every size is below. @@ -194,27 +199,27 @@ fixtures; the fit over every size is below. | formula | numerator bits | denominator bits | intermediate bits | unsigned bits | headroom | |---|---|---|---|---|---| -| fixture A: mean, variance, range | 20 | 27 | 27 | 0 | 36 | -| fixture B: mean, variance, range | 20 | 32 | 32 | 0 | 31 | -| fixture C: mean, variance, range | 20 | 20 | 17 | 0 | 43 | -| fixture D: mean, variance, range | 20 | 22 | 22 | 0 | 41 | -| fixture E: mean, variance, range | 20 | 20 | 6 | 0 | 43 | -| fixture F: mean, variance, range | 20 | 22 | 22 | 0 | 41 | -| fixture A: rejection, 6 % of the mean | 12 | 21 | 21 | 0 | 42 | -| fixture B: rejection, 7/4 standard deviations | 18 | 35 | 35 | 0 | 28 | -| fixture B: rejection, gap to range 9/20 | 14 | 16 | 16 | 0 | 47 | -| fixture A: spread at 2 dp | 20 | 27 | 27 | 19 | 36 | -| fixture A: spread at 3 dp | 20 | 27 | 27 | 26 | 36 | -| fixture A: spread at 4 dp | 20 | 27 | 27 | 33 | 36 | -| fixture A: spread at 6 dp | 21 | 29 | 29 | 46 | 34 | -| fixture F: exact root at 0 dp | 20 | 22 | 22 | 0 | 41 | -| passing from the cumulative retained, 5 screens | 17 | 10 | 17 | 0 | 46 | -| interpolation along a 5-point grading curve | 13 | 12 | 13 | 0 | 50 | -| 20 masses at 3 dp: mean, variance, range | 25 | 45 | 45 | 0 | 18 | -| 64-point grading curve: cumulative percentages, one reading | 26 | 24 | 26 | 0 | 37 | -| 20 masses at 3 dp: spread at 3 dp | 25 | 45 | 48 | 40 | 15 | -| least squares, the 4-point fixture: slope and intercept | 11 | 17 | 17 | 0 | 46 | -| least squares, 5 points on distinct denominators (stress control) | 21 | 20 | 21 | 0 | 42 | +| fixture A: mean, variance, range | 20 | 27 | 27 | 0 | 100 | +| fixture B: mean, variance, range | 20 | 32 | 32 | 0 | 95 | +| fixture C: mean, variance, range | 20 | 20 | 17 | 0 | 107 | +| fixture D: mean, variance, range | 20 | 22 | 22 | 0 | 105 | +| fixture E: mean, variance, range | 20 | 20 | 6 | 0 | 107 | +| fixture F: mean, variance, range | 20 | 22 | 22 | 0 | 105 | +| fixture A: rejection, 6 % of the mean | 12 | 21 | 21 | 0 | 106 | +| fixture B: rejection, 7/4 standard deviations | 18 | 35 | 35 | 0 | 92 | +| fixture B: rejection, gap to range 9/20 | 14 | 16 | 16 | 0 | 111 | +| fixture A: spread at 2 dp | 20 | 27 | 27 | 19 | 100 | +| fixture A: spread at 3 dp | 20 | 27 | 27 | 26 | 100 | +| fixture A: spread at 4 dp | 20 | 27 | 27 | 33 | 100 | +| fixture A: spread at 6 dp | 21 | 29 | 29 | 46 | 98 | +| fixture F: exact root at 0 dp | 20 | 22 | 22 | 0 | 105 | +| passing from the cumulative retained, 5 screens | 17 | 10 | 17 | 0 | 110 | +| interpolation along a 5-point grading curve | 13 | 12 | 13 | 0 | 114 | +| 20 masses at 3 dp: mean, variance, range | 25 | 45 | 45 | 0 | 82 | +| 64-point grading curve: cumulative percentages, one reading | 26 | 24 | 26 | 0 | 101 | +| 20 masses at 3 dp: spread at 3 dp | 25 | 45 | 48 | 40 | 79 | +| least squares, the 4-point fixture: slope and intercept | 11 | 17 | 17 | 0 | 110 | +| least squares, 5 points on distinct denominators (stress control) | 21 | 20 | 21 | 0 | 106 | @@ -229,25 +234,26 @@ overflow left. | formula | resolution | overflowed | least headroom | |---|---|---|---| -| variance | 4 dp | 0 of 1000 | 11 | -| variance | 5 dp | 0 of 1000 | 4 | -| variance | 6 dp | 423 of 1000 | 0 | -| rejection by 7/4 standard deviations | 4 dp | 0 of 1000 | 7 | -| rejection by 7/4 standard deviations | 5 dp | 0 of 1000 | 0 | -| rejection by 7/4 standard deviations | 6 dp | 897 of 1000 | 0 | -| rejection by 6 % of the mean | 4 dp | 0 of 1000 | 33 | -| rejection by 6 % of the mean | 5 dp | 0 of 1000 | 29 | -| rejection by 6 % of the mean | 6 dp | 0 of 1000 | 26 | +| variance | 4 dp | 0 of 1000 | 75 | +| variance | 5 dp | 0 of 1000 | 68 | +| variance | 6 dp | 0 of 1000 | 62 | +| rejection by 7/4 standard deviations | 4 dp | 0 of 1000 | 71 | +| rejection by 7/4 standard deviations | 5 dp | 0 of 1000 | 64 | +| rejection by 7/4 standard deviations | 6 dp | 0 of 1000 | 58 | +| rejection by 6 % of the mean | 4 dp | 0 of 1000 | 97 | +| rejection by 6 % of the mean | 5 dp | 0 of 1000 | 93 | +| rejection by 6 % of the mean | 6 dp | 0 of 1000 | 90 | The named sample 40.053270, 39.475922, 39.025798, 40.615904, 39.418416 and -40.131659 g overflows in its variance, and so does its rejection by 7/4 -standard deviations; a census test holds both. +40.131659 g has a variance of exactly 2026588050217/6000000000000 g², and a +rejection by 7/4 standard deviations; a census test holds both. A criterion relative to the mean compares a deviation with a limit and squares nothing, so it keeps a wide margin at any resolution. Criteria in -standard deviations square twice, and are the first to run out. +standard deviations square twice, and use the most bits: at 6 decimal places +they leave 58, where a criterion relative to the mean leaves 90. ### Exact sizes (realistic) @@ -256,15 +262,17 @@ standard deviations square twice, and are the first to run out. coherent unit the evaluator works in (kg², Pa), and in the result's declared unit (g², MPa), the unit `checked_evaluate` returns. The census test and CTest's `census.exact-sizes-self-check` hold both generators to the same -literals, and `census.exact-sizes` holds these figures: +literals, and `census.exact-sizes` holds these figures. Every value fits 128 +bits in either unit; in SI the widest variance needs 65 bits and the widest +strength 64: ```text -six masses near 40 g at 4 dp: the exact variance does not fit 64 bits in 0 of 1000 in kg2 (SI), in 0 of 1000 in g2 (declared; widest 32 bits) -six masses near 40 g at 5 dp: the exact variance does not fit 64 bits in 0 of 1000 in kg2 (SI), in 0 of 1000 in g2 (declared; widest 39 bits) -six masses near 40 g at 6 dp: the exact variance does not fit 64 bits in 374 of 1000 in kg2 (SI), in 0 of 1000 in g2 (declared; widest 45 bits) -4F / (pi * d^2), F = 89.3 kN, d = 101 to 163 mm: in Pa (SI) the exact strength needs 64 bits, more than a signed 64-bit integer's 63, at d = 101 (64 bits), 103 (64 bits), 107 (64 bits), 109 (64 bits), 113 (64 bits), 119 (64 bits), 121 (64 bits), 127 (64 bits), 131 (64 bits), 137 (64 bits), 139 (64 bits), 143 (64 bits), 149 (64 bits), 151 (64 bits), 157 (64 bits), 161 (64 bits), 163 (64 bits) +six masses near 40 g at 4 dp: the exact variance does not fit 128 bits in 0 of 1000 in kg2 (SI; widest 52 bits), in 0 of 1000 in g2 (declared; widest 32 bits) +six masses near 40 g at 5 dp: the exact variance does not fit 128 bits in 0 of 1000 in kg2 (SI; widest 59 bits), in 0 of 1000 in g2 (declared; widest 39 bits) +six masses near 40 g at 6 dp: the exact variance does not fit 128 bits in 0 of 1000 in kg2 (SI; widest 65 bits), in 0 of 1000 in g2 (declared; widest 45 bits) +4F / (pi * d^2), F = 89.3 kN, d = 101 to 163 mm: in Pa (SI) the exact strength does not fit 128 bits at 0 of 63 (widest 64 bits) 4F / (pi * d^2), F = 89.3 kN, d = 101 to 163 mm: in MPa (declared) it does not fit at 0 of 63 (widest 44 bits) ``` @@ -280,22 +288,21 @@ load of 89.3 kN: | formula | overflows at d = | refused at | least headroom otherwise | |---|---|---|---| -| area, pi * d^2 / 4 (the expressions example) | none | -- | 15 | -| strength, 4F / (pi * d^2), F = 89.3 kN, in MPa (the methods example's cylinder) | 101, 103, 107, 109, 113, 119, 121, 127, 131, 137, 139, 143, 149, 151, 157, 161, 163 mm | 4F / (pi * d^2) | 0 | +| area, pi * d^2 / 4 (the expressions example) | none | -- | 79 | +| strength, 4F / (pi * d^2), F = 89.3 kN, in MPa (the methods example's cylinder) | none | -- | 63 | -**The area fits; dividing by it does not.** The product π d² itself never -overflows. The strength divides by it, which puts π's 27-bit denominator into -the numerator, next to the force (4 × 89,300 N, 19 bits) and the 10^6 of -mm² to m² (20 bits): 4F × 78256779 × 10^6 needs 64.6 bits. It fits only -when d² cancels enough of it -- a diameter with a factor of 2, 3 or 5, as -135 = 3³ × 5 has, or of 19, which divides this force (133 = 7 × 19). Every -other diameter in the range leaves a 64-bit numerator in pascals: the exact -strength in SI, a value the evaluator holds before its last conversion. In -megapascals, the declared unit, it needs at most 44 bits; the 10^6 is the -whole difference. "Refused at" is the step the arithmetic refused, re-done -by hand in the evaluator's order. +**Dividing by the area is what costs bits.** The product π d² itself stays +small. The strength divides by it, which puts π's 27-bit denominator into the +numerator, next to the force (4 × 89,300 N, 19 bits) and the 10^6 of mm² to +m² (20 bits): 4F × 78256779 × 10^6 needs 64.6 bits, more than a 64-bit +integer holds unless d² cancels some of it. In pascals, the exact strength in +SI that the evaluator holds before its last conversion, the widest needs 64 +bits; in megapascals, the declared unit, at most 44; the 10^6 is the whole +difference. 128 bits hold every one, at 139 mm as at 135 mm. "Refused at" +would name the step the arithmetic refused, re-done by hand in the +evaluator's order; no step is refused. ### Stress controls @@ -303,10 +310,10 @@ These are asserted by the census program's own tests. | case | result | |---|---| -| (2^62 − 1) + 2^62 = 2^63 − 1, from operands of 62 and 63 bits | the addition's own intermediate uses 63 bits: headroom 0 | -| 2^31 × 2^30 = 2^61, from operands of 32 and 31 bits | the product uses 62 bits: headroom 1 | -| (2^63 − 1) + 1 | `Overflow`, no figure | -| 2^32 × 2^31 | `Overflow`; the count holds nothing past the operands' 33 bits | +| (2^126 − 1) + 2^126 = 2^127 − 1, from operands of 126 and 127 bits | the addition's own intermediate uses 127 bits: headroom 0 | +| 2^63 × 2^62 = 2^125, from operands of 64 and 63 bits | the product uses 126 bits: headroom 1 | +| (2^127 − 1) + 1 | `Overflow`, no figure | +| 2^64 × 2^63 | `Overflow`; the count holds nothing past the operands' 65 bits | | (2^40 / 3) × (3 / 2^20) = 2^20 | intermediates within 21 bits, because a product is cross-reduced before it is formed | | a sum evaluated at compile time | nothing reported | @@ -319,11 +326,13 @@ unconverted: readings at 1 decimal place; readings at 3 decimal places of a few thousand newtons, a load cell's; and a different denominator on every point, the stress control. Every size from 2 to 128 points is fitted through `LinearLeastSquares::compute`, the fit the node calls, and the node itself -is checked against it at 33 and 34 points. The last two rows fit the same -shapes the way `rounded_output` does: the slope reported to 4 decimal places -of N/s, computed by `LinearLeastSquares::compute_exact` in 256-bit integers -and rounded exactly; the node is checked against that at 57, 58 and 128 -points. For those two rows the last column counts the 64-bit integers only, +is checked against it at 27 and 28 points on a different denominator for +every point, and at 128 on the readings at 3 decimal places. The last two +rows fit the same shapes the way `rounded_output` does: the slope reported to +4 decimal places of N/s, computed by `LinearLeastSquares::compute_exact` in +256-bit integers and rounded exactly; the node is checked against that at 57, +58 and 128 points. For those two rows the last column counts `Rational`'s +128-bit integers only, the rounded result and its conversion among them, and not the fit's 256-bit intermediates, which the census does not see: they reach 68 bits on the readings at 3 decimal places, and up to 249 of the 256 on a different @@ -334,19 +343,21 @@ figure there says nothing of how close the fit came to its 256 bits. | data (invented) | sizes that overflow | first to overflow | least headroom otherwise | |---|---|---|---| -| readings at 1 dp (realistic) | 0 of 127 | none | 29 | -| readings at 3 dp near 2410 N, a load cell's (realistic) | 57 of 127 | 34 points | 0 | -| a different denominator on every point (stress control) | 114 of 127 | 15 points | 2 | -| the slope rounded to 4 dp by rounded_output: readings at 3 dp near 2410 N (realistic) | 0 of 127 | none | 41 | -| the slope rounded to 4 dp by rounded_output: a different denominator on every point (stress control) | 71 of 127 | 58 points | 48 | +| readings at 1 dp (realistic) | 0 of 127 | none | 93 | +| readings at 3 dp near 2410 N, a load cell's (realistic) | 0 of 127 | none | 54 | +| a different denominator on every point (stress control) | 101 of 127 | 28 points | 6 | +| the slope rounded to 4 dp by rounded_output: readings at 3 dp near 2410 N (realistic) | 0 of 127 | none | 105 | +| the slope rounded to 4 dp by rounded_output: a different denominator on every point (stress control) | 71 of 127 | 58 points | 112 | -**Overflow depends on the data far more than on the number of points.** At -3 decimal places the first size to overflow is 34 points, but not every -larger size does. So no number of points is safe to state; an overflowing -fit is `Overflow`, never a line. Where it overflows, a method that states the -precision it reports the slope at gets that instead, from `rounded_output`: +**Overflow depends on the data far more than on the number of points.** +Readings at 1 and at 3 decimal places fit at every size up to 128 points, +with at least 54 bits to spare; a different denominator on every point +overflows from 28 points. So no number of points is safe to state for every +kind of data; an overflowing fit is `Overflow`, never a line. Where it +overflows, a method that states the precision it reports the slope at gets +that instead, from `rounded_output`: exact, traced and documented, at every size here for readings at 3 decimal places, and `Overflow` from 58 points on a different denominator for every point, where even 256 bits are outgrown. There is no traced fallback in @@ -375,41 +386,56 @@ headroom figure to print, and none is implied. | data (invented) | exact route: sizes that overflow | first | rounded route: sizes that overflow | first | |---|---|---|---|---| -| a line through readings at 3 dp near 2410 N (realistic) | 99 of 127 | 29 points | 0 of 127 | none | -| a line through readings at 4 dp near 2410 mm (realistic) | 122 of 127 | 7 points | 0 of 127 | none | -| a line through a different denominator on every point (stress control) | 118 of 127 | 11 points | 66 of 127 | 62 points | -| two regressors: readings at 3 dp and a temperature at 1 dp in degrees Celsius (realistic) | 100 of 126 | 29 points | 0 of 126 | none | +| a line through readings at 3 dp near 2410 N (realistic) | 0 of 127 | none | 0 of 127 | none | +| a line through readings at 4 dp near 2410 mm (realistic) | 0 of 127 | none | 0 of 127 | none | +| a line through a different denominator on every point (stress control) | 107 of 127 | 22 points | 66 of 127 | 62 points | +| two regressors: readings at 3 dp and a temperature at 1 dp in degrees Celsius (realistic) | 0 of 126 | none | 0 of 126 | none | -**The exact route stops early; the rounded route does not stop on realistic -data.** A call's outputs answer or fail together, and R²'s exact fraction is -about twice as wide as the slope's. Reported at declared decimals, the same -fits answer at every size measured. A different denominator on every point -outgrows even the wide kernel, and is `Overflow`. +**Neither route stops on realistic data.** A call's outputs answer or fail +together, and R²'s exact fraction is about twice as wide as the slope's, so +the exact route is the first to stop: on a different denominator on every +point it stops at 22 points. Reported at declared decimals, the same fit +answers at every size below 62 points, the first at which it outgrows even +the wide kernel, and is `Overflow`. ## Which cases decide -Under 8 bits, and realistic: the **sample variance at 5 and 6 decimal -places** of a gram, **rejection by standard deviations at 4, 5 and 6 -decimal places**, and **a cylinder's strength** at the diameters the table -names, and in the methods example even at 135 mm, where it fits; and -**a least-squares line through 3-decimal readings** from 34 points. A balance -reading to 0.01 mg or 1 µg is ordinary laboratory equipment, and so is a -139 mm cylinder, so these are not contrived. The -cases with a wide margin are the ones that add or scale values at a -resolution of 0.1 g or coarser, or that do not square. +No realistic formula measured is under 8 bits. The ones that come closest +square values read at fine resolution, and they are the ones a future change +would push under the line first: **a least-squares line through 3-decimal +readings** (54 bits left), **rejection by standard deviations at 6 decimal +places** (58), the **sample variance at 6 decimal places** of a gram (62) and +**a cylinder's strength** (63). A balance reading to 1 µg is ordinary +laboratory equipment, and so is a 139 mm cylinder, so these are not +contrived. The cases with a wide margin are the ones that add or scale values +at a resolution of 0.1 g or coarser, or that do not square. The one program +under the line, `opaque_and_retry`, is there on purpose: its fit on a +different denominator for every point is built to overflow. ## What this does not decide -The census builds neither remedy. 128-bit intermediate arithmetic would -compute each product and sum in 128 bits before reducing; a wider stored -representation would offer a fixed-width wide-integer `Rational` as a `Rep`. -[Issue #1](https://github.com/LASTRADA-Software/formula-cpp/issues/1) tracks -the choice between them. Beside them, a formula can declare the precision a -value is reported at, and `rounded_output` computes that decimal in wider -integers ([Displaying numbers](display.md#values-the-exact-layer-cannot-hold)); -that answers for the one output reported, not for `Rational` itself. An +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. +- **The logarithm and exponential kernel** (`detail/transcendental.hpp`) + takes an argument whose numerator and denominator each fit 64 bits, the + range it was built for; a wider argument, which a `Rational` can now hold, + is `Overflow`, and so is the exponential of more than 44. Two kinds of + wide argument never reach it: a power of ten, 10^19 up to 10^38 or one + over it, whose logarithm is exact, and an exponential of less than -43, + which is 0, or one unit under `Ceiling` and `AwayFromZero`, whatever its + width. +- **The 64-bit fields** of `Unit`, `Band` and `Breakpoint`: `band` and + `breakpoint` refuse a `Rational` bound or key that does not fit them. + +Beside the exact arithmetic, a formula can declare the precision a value is +reported at, and `rounded_output` computes that decimal in wider integers +([Displaying numbers](display.md#values-the-exact-layer-cannot-hold)); that +answers for the one output reported, not for `Rational` itself. An arbitrary-precision integer is out of scope: it allocates, which in `noexcept` code turns running out of memory into `std::terminate`, and it cannot run at compile time. @@ -423,7 +449,8 @@ change that quietly spends more headroom fails there. Measured: without `checked_mul`'s cross-reduction, the curve pin and the spread's numerator pin fail, and so does the cross-reduction control; with a sum scaled by the product of the denominators instead of their least common multiple, the -twenty masses' variance no longer evaluates. +twenty masses' headroom pin fails (at 64 bits the variance did not evaluate +at all). To run the census yourself, build and run `formula-cpp-census-tests`; the census builds of the examples and the gallery print their line when they diff --git a/docs/opaque-and-retry.md b/docs/opaque-and-retry.md index 829b5b91..cf64536d 100644 --- a/docs/opaque-and-retry.md +++ b/docs/opaque-and-retry.md @@ -152,7 +152,7 @@ constexpr auto highestLessLowest = formula::opaque_output<"highest">(spanCall) - 4. r = 127 g; 103 g; 191 g; 139 g 5. series span(#4) = lowest = 103 g; highest = 191 g; span = 88 g [inside not shown] [Spread of readings, Example Standard 12, 4.2] 6. lowest of #5 = 103 g -7. #3 - #6 = 11/125 +7. #3 - #6 = 88 g ``` ```text @@ -200,20 +200,20 @@ one point: argument outside the domain of the operation - **Overflow, never a wrong line.** The fit sums, over the points, squares and products of each point's coordinates about their means, and `Rational` - keeps each numerator and denominator in 64 bits. + keeps each numerator and denominator in 128 bits. When an exact sum does not fit, the result is `Overflow`: ```text -fifteen distinct denominators: overflow in exact arithmetic +twenty-seven distinct denominators: overflow in exact arithmetic ``` **When a fit overflows depends on the data far more than on the number of -points.** Measured on cl 19.51, clang-cl and clang++ 22.1.3, g++ 13.3 and -g++ 14.2, with identical results on all five: integers, readings at one -decimal place, and thirds mixed with sevenths never overflow for 2 to 128 -points; readings at three decimal places of a few thousand first overflow at -34 points, and not at every larger size; a different denominator on every -point overflows from 15. So there is no safe number of points to state. The +points.** Measured, and the same, on cl 19.51, clang-cl 22.1, g++ 14.2 and +clang++ 20.1: readings at one decimal place, and readings at three decimal +places of a few thousand, never overflow for 2 to 128 points; a different +denominator on every point overflows from 28, and the example's, in +millimetres, overflows at 27. +So there is no safe number of points to state. The [numeric headroom](numeric-headroom.md) page carries the fit's census over every size, regenerated with every build. **A fit that overflows has a traced answer only at a declared precision** (`rounded_output`, below), **and @@ -263,11 +263,11 @@ round(linear least squares(t(i), L(i)).slope, to 4 dp of mm/s) correct rounding of 19/28 mm/s, and the step's value; no number style marks it approximate. - **It answers where the exact fit overflows.** `linear_least_squares` - computes the fit for it in 256-bit integers, and the fifteen distinct + computes the fit for it in 256-bit integers, and the twenty-seven distinct denominators that overflow above give a slope: ```text -fifteen distinct denominators, rounded where used: 116.232 mm/min +twenty-seven distinct denominators, rounded where used: 122.238 mm/min ``` - **It still refuses rather than guess.** A different denominator on every @@ -323,10 +323,10 @@ the four. ### When the exact fractions do not fit -An exact fit through fifty readings at four decimals does not fit -`Rational`. Computed with Python's fractions, the slope is a fraction of 46 -and 54 bits, which fits, but the intercept's numerator needs 64 bits and R² -92 bits over 92. `opaque_output` then answers `Overflow` -- for every +An exact fit through fifty readings at eight decimals does not fit +`Rational`. Computed with Python's fractions, the slope is a fraction of 65 +and 73 bits and the intercept of 93 and 91, which fit, but R² needs 130 bits +over 130. `opaque_output` then answers `Overflow` -- for every output of the call, since its outputs answer or fail together. A formula that declares the precision it reports a coefficient at -- a unit, decimal places and a rounding mode, as `rounded<>` does -- gets the correctly @@ -352,12 +352,12 @@ round(linear least squares(t(i), L(i)).slope, to 4 dp of mm/s) ``` ```text -fifty readings at 4 decimals, exact: overflow in exact arithmetic -fifty readings at 4 decimals, rounded: slope 3.1707 mm/s, intercept 2406.6455 mm, r squared 0.999996 +fifty readings at 8 decimals, exact: overflow in exact arithmetic +fifty readings at 8 decimals, rounded: slope 3.1707 mm/s, intercept 2406.6454 mm, r squared 0.999996 ``` The slope is 3.1707 mm/s at four decimals (a floor would give 3.1706), the -intercept 2406.6455 mm, and R² 0.999996 floored at six decimals (to nearest +intercept 2406.6454 mm, and R² 0.999996 floored at six decimals (to nearest it would be 0.999997). ### R² as an acceptance @@ -542,11 +542,11 @@ ended: 32. 152/25 g 33. w(k-1) = 266/25 g 34. 2 -35. #33 / #34 = 133/25000 -36. #32 + #35 = 57/5000 +35. #33 / #34 = 133/25 g +36. #32 + #35 = 57/5 g 37. w(k-1) = 266/25 g 38. w(k) = 57/5 g -39. #37 - #38 = -19/25000 +39. #37 - #38 = -19/25 g 40. -19/25 g 41. attempt 4: w(k) = #36 = 57/5 g; judged #39 >= #40: accepted 42. w = retry: accepted at attempt 4 of 4 = 57/5 g [Settled estimate, Example Standard 12, 6] diff --git a/docs/quantities.md b/docs/quantities.md index 1ccb9d23..9d671bf7 100644 --- a/docs/quantities.md +++ b/docs/quantities.md @@ -309,8 +309,9 @@ formula::Measured const fractional { 10.3_r }; ``` `10.3_r` is exactly 103/10. A plain `10.3` is refused with a message that -says why -- it is the double nearest 10.3, not 10.3 -- and so is an unsigned -integer wide enough to hold values a `Rational` cannot. +says why -- it is the double nearest 10.3, not 10.3. Every built-in integer +of up to 64 bits, `std::uint64_t` among them, converts exactly; a wider one +is refused. `formula::measured_series` takes the same spellings, mixed freely, and `formula::not_measured` for a point that was not measured. It is the same diff --git a/docs/rounding-and-conditionals.md b/docs/rounding-and-conditionals.md index ce0ce634..f1a69c92 100644 --- a/docs/rounding-and-conditionals.md +++ b/docs/rounding-and-conditionals.md @@ -225,7 +225,7 @@ that did not run: 2. 173/10 mm 3. d = 127/5 mm 4. round(#3, to 0 dp of mm) = 25 mm [nearest, ties away from zero] -5. if #1 > #2 then #4 = 1/40 +5. if #1 > #2 then #4 = 25 mm ``` Step 5 is the conditional. `#1` and `#2` are the predicate's two sides, @@ -257,13 +257,13 @@ rather than an inconsistency: a rendered formula states what a method says, while a trace explains why one particular number came out as it did, and the tie rule can be the entire reason a value is 13 rather than 12. -Note too that step 5's own value, `1/40`, carries no `mm` -- a `when()` -step is a computed value like any other, and every computed step is shown in -the coherent unit of its dimension with no symbol at all, the same rule -[Tracing and audit trails](tracing.md) explains for `#1 / #2` in a plain -division. For a second worked derivation of a conditional -- a different -formula, a different threshold, still naming the branch it took -- see -[the gallery](gallery.md). +Note too that step 5's own value, `25 mm`, reads in the unit of the branch it +names: a `when()` step's value is its chosen branch's, so it is shown in the +unit that branch's line shows, millimetres here -- one of the rules +[Tracing and audit trails](tracing.md#reading-a-derivation) gives for the unit +a computed step borrows from the steps it read. For a second worked derivation +of a conditional -- a different formula, a different threshold, still naming +the branch it took -- see [the gallery](gallery.md). ## The traced escape hatch: `numeric_value_of` diff --git a/docs/series.md b/docs/series.md index 9bf42a6b..88e28ba3 100644 --- a/docs/series.md +++ b/docs/series.md @@ -103,13 +103,16 @@ names the two screens the answer lay between: 10. interpolate(#8, at #9) = 6927/10625 [between 163 and 197 m] ``` -A computed step has no declared unit, as a computed single value has none, so -it reads in the **coherent unit**: the SI unit of its dimension, with no prefix -([Expressions and evaluation](expressions.md)), times one of each +A computed step with no unit to borrow from the steps it read reads in the +**coherent unit**, as a computed single value does: the SI unit of its +dimension, with no prefix ([Expressions and evaluation](expressions.md)), +times one of each [named base dimension](dimensions.md#base-dimensions-the-si-does-not-have) it -carries -- the euro, for an amount in euros. For a percentage that is a plain -fraction, so 447/1250 is 35.76 % and 6927/10625 is 27708/425 %. For a mass it -is the kilogram. +carries -- the euro, for an amount in euros -- written after the number. A +ratio of two masses is a pure number, and a percentage less a pure number is +in two units, so for steps 6 and 7 that is a plain fraction, and for step 10, +read off step 7's values, too: 447/1250 is 35.76 % and 6927/10625 is +27708/425 %. For a mass it is `kg`. A few computed steps are still in their series' unit, and say so: a running total, a `sum` and a range read in the unit of the series they add up, and a @@ -133,8 +136,8 @@ is 922.35 K, the range 17.6 K and the mean 34.3 °C: ```text the readings: 1. T_r = 237/10 °C; 413/10 °C; 379/10 °C -their sum: 2. sum(#1) = 18447/20 -their range: 2. sample_range(#1) = 88/5 +their sum: 2. sum(#1) = 18447/20 K +their range: 2. sample_range(#1) = 88/5 K their mean: 2. sample_mean(#1) = 343/10 °C ``` diff --git a/docs/statistics.md b/docs/statistics.md index 51d203a6..dccc2a43 100644 --- a/docs/statistics.md +++ b/docs/statistics.md @@ -107,13 +107,13 @@ round(sqrt(sample_variance(m(i))), to 2 dp of g) = 37/20 g LaTeX: \operatorname{round}_{2\,\mathrm{g}}(\sqrt{s^{2}({m}_{i})}) ``` -The trace shows the variance as the evaluator holds it, in the coherent unit -(kg², so 427/125 g² is 427/125000000), and the root rounded in the unit the -formula declares: +The trace shows the variance in the coherent unit, written `kg^2` after it -- +no unit of the formula's names a squared mass, so 427/125 g² reads +427/125000000 kg^2 -- and the root rounded in the unit the formula declares: ```text 1. m = 201/5 g; 199/5 g; 81/2 g; 44 g; 40 g; 433/10 g -2. sample_variance(#1) = 427/125000000 +2. sample_variance(#1) = 427/125000000 kg^2 3. round(sqrt(#2), to 2 dp of g) = 37/20 g [nearest, ties away from zero] ``` @@ -179,22 +179,26 @@ at first -- in pass 2, and pass 3 settles: 1. m = 201/5 g; 199/5 g; 81/2 g; 44 g; 40 g; 433/10 g 2. 3/50 3. pass mean = 413/10 g -4. #2 * #3 = 1239/500000 +4. #2 * #3 = 1239/500 g 5. pass 1: 6 values, mean 413/10 g 6. rejected element 4 of 6 (44 g) in pass 1: abs(x - mean) = 27/10 g > 1239/500 g (deviation from mean) 7. 3/50 8. pass mean = 1019/25 g -9. #7 * #8 = 3057/1250000 +9. #7 * #8 = 3057/1250 g 10. pass 2: 5 values, mean 1019/25 g 11. rejected element 6 of 6 (433/10 g) in pass 2: abs(x - mean) = 127/50 g > 3057/1250 g (deviation from mean) 12. 3/50 13. pass mean = 321/8 g -14. #12 * #13 = 963/400000 +14. #12 * #13 = 963/400 g 15. pass 3: 4 values, mean 321/8 g 16. settled: 2 rejected, 4 remain 17. sample_mean(#16) = 321/8 g ``` +Each pass's limit is 3/50 of that pass's mean: a mean in grams scaled by a pure +number, so it reads in grams as the mean does. Line 4's 1239/500 g is 2.478 g, +and line 6 states it again beside the deviation it was compared with. + **An abort is the author's verdict.** When the next rejection would pass `AtMost` or `KeepAtLeast`, nothing more is rejected: the trace records the author's `Verdict` and its citation, and anything reduced from the rejection @@ -323,9 +327,9 @@ LaTeX: \text{require } \left\lvert x_A - x_B\right\rvert \leq r\left(1/10\,\math 11. 1/10 g 12. 1/50 13. level = 16181/400 g [bound by #16] -14. #12 * #13 = 16181/20000000 -15. #11 + #14 = 18181/20000000 -16. r at level #10 (pass 2 of 2) = #15 = 18181/20000000 +14. #12 * #13 = 16181/20000 g +15. #11 + #14 = 18181/20000 g +16. r at level #10 (pass 2 of 2) = #15 = 18181/20000 g 17. require #4 <= #16 [satisfied] ``` @@ -354,10 +358,13 @@ no rendering in any dialect holds a `|`. ## How much room exact arithmetic has -Statistics of determinations read at fine resolution are where a 64-bit -exact fraction runs out first: a variance of masses read to 1 µg overflows on -about four samples in ten, and a rejection in standard deviations sooner. -The result is then `Overflow`, never a wrong number. See +Statistics of determinations read at fine resolution are where an exact +fraction's integers grow fastest: the exact variance of six masses near 40 g +read to 1 µg needs up to 65 bits in kg². `Rational`'s 128-bit integers hold +it for every one of the 1000 samples measured, with at least 62 of their 127 +bits to spare, and a rejection in standard deviations with at least 58. +Where a computation does outgrow them, the result is `Overflow`, never a +wrong number. See [Numeric headroom](numeric-headroom.md) for the measurements. ## What is not modelled diff --git a/docs/superpowers/plans/2026-10-03-int128-and-trace-units.md b/docs/superpowers/plans/2026-10-03-int128-and-trace-units.md new file mode 100644 index 00000000..9fb795bd --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-int128-and-trace-units.md @@ -0,0 +1,3084 @@ +# Int128 and Trace Units 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 issue #2 and issue #1 in one pull request. + +- **#2:** every computed value in a trace prints with a unit, read off its operand steps where that is safe and the coherent unit's symbol otherwise. +- **#1:** `Rational` stores a new `formula::Int128`, so realistic laboratory statistics stop overflowing. + +**Architecture:** Two independent changes, built in two lanes that merge at the end. + +- **Trace units.** The recorder (`RecordingSink::produced`, `trace.hpp`) chooses a borrowed unit from the operand steps. The renderer (`value_in_declared_unit`, `trace_render.hpp`) writes the coherent unit's symbol after any dimensioned value whose unit has none. +- **Int128.** + - A new header, `int128.hpp`, adds `Int128`. It is held as two 64-bit words everywhere; the compiler's `__int128` computes where it has one, and portable `constexpr` code everywhere else. + - A prep task moves every reader of `numerator()` and `denominator()` onto width-agnostic helpers while `Rational` is still 64-bit, with no behaviour change. + - A switch task then flips `Rational::Int` to `Int128` and re-measures the overflow census. + +**Tech Stack:** C++23, header-only; Catch2 3.6 (`STATIC_REQUIRE`), the `test/negative/` harness, the CTest docs and census checks, Python 3 for `tools/census/exact_sizes.py`. + +**Specs (read both before any task):** + +- `docs/superpowers/specs/2026-10-03-trace-units-design.md`: issue #2, Tasks 1–3. +- `docs/superpowers/specs/2026-10-03-int128-rational-design.md`: issue #1, Tasks 4–7. + +**Two refinements of the `Int128` spec, decided while planning, which bind Tasks 4–7.** The spec is amended to match, in the commit that adds this plan: + +1. **No conversion to a built-in integer at all.** The approved spec listed `explicit` conversions. Without them, every `static_cast(r.denominator())` in the library becomes a compile error at the switch instead of a silent cut to 64 bits. Narrowing is spelled `to_int64()` and `to_uint64()`, which return `std::optional`. +2. **One storage layout.** `Int128` is two `std::uint64_t` words on every compiler. Multiplication, division and the overflow check run on the compiler's `unsigned __int128` where it has one, converting in and out, which optimises to nothing. The spec's native-storage variant would have meant two class layouts. + +**Order and lanes:** + +- Lane A, trace units, is Tasks 1 → 2 → 3, in worktree `D:\formula-cpp\.claude\worktrees\int128-and-trace-units`, on branch `feature/int128-and-trace-units`. +- Lane B, Int128, is Tasks 4 → 5 → 6 → 7, in worktree `D:\formula-cpp\.claude\worktrees\int128-core`, on branch `feature/int128-core`. That branch is cut from `feature/int128-and-trace-units` at the commit that adds this plan. +- The lanes run at the same time and share no file except `CHANGELOG.md` and `docs/numeric-headroom.md` (generated). +- Task 8 merges Lane B into `feature/int128-and-trace-units`, regenerates what both touched, and runs the full verification. + +**Line anchors** were read at `ff148f1`. 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.** Each lane works only in its own worktree: Lane A in `D:\formula-cpp\.claude\worktrees\int128-and-trace-units`, Lane B in `D:\formula-cpp\.claude\worktrees\int128-core`. Never touch `D:\formula-cpp` itself or the other lane's worktree. +- **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\e2d32a6f-b4c6-5a36-8b79-7a082a83081b\scratchpad` + - `$T` = the lane's worktree. +- **Verify, the per-task gate.** Every command must print `ALL OK` / `MATRIX 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 ""`, only for a task that adds or touches negative tests. It runs the negatives matching `` on cl-debug **and** clangcl-debug; `EXPECT_COUNT` is checked only off MSVC. + 3. Task 4 only, because the native `__int128` path exists only on GCC and Clang: `wsl bash /mnt/c/Users/c.parpart/AppData/Local/Temp/claude/D--formula-cpp/e2d32a6f-b4c6-5a36-8b79-7a082a83081b/scratchpad/posix-matrix.sh --tree /mnt/d/formula-cpp/.claude/worktrees/int128-core --presets "gcc-release clang-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 `ff148f1`:** cl-debug passes 1527 of 1527 non-negative tests; clangcl-debug passes 560 of 560 negative tests. Each task reports its total and the difference from the previous task's, which must equal its stated delta. +- **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`), so formatters live in `format.hpp`; + - every public `static_assert` message begins `formula: `; + - a new public header goes into the install `FILE_SET` (`CMakeLists.txt:41-106`, `hygiene.installed-headers`) and into `test/consumer_globals_tests.cpp`'s includes (`hygiene.consumer-globals`). +- **Consumer globals.** `test/consumer_globals_tests.cpp:130-153` declares a list of `int` globals. 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 that this plan is tempted by: + - `a`–`z`; + - `value`, `values`, `result`, `sum`, `count`, `digits`, `numerator`, `denominator`, `quotient`, `sign`, `width`, `scale`, `scaled`, `factor`; + - `low`, `high`, `lower`, `upper`, `limit`, `pattern`, `root`, `step`, `text`, `unit`, `kind`, `first`, `last`, `left`, `right`, `operand`, `number`, `total`, `range`, `rest`. + + Use descriptive names (`leftOperand`, `magnitudeOf`, `highWord`, `rootSoFar`). Member names are not affected. +- **Behaviour rule (#1 spec §4).** Every computation that answers today gives the same answer afterwards. Some that are refused with `Overflow` today now answer. A refusal is never turned into a different number. +- **Display rule (#2 spec §2).** No computed dimensioned value prints without a unit, and no number is shown in a unit other than the one written after it. +- **No internal labels in public text.** Code, docs, commit messages and the PR never name tasks, lanes, plans or reviewers, and never cite local progress notes. Every sentence must make sense to a reader who never saw this plan. +- **No third-party standard content.** Cite only `Example Standard N:YYYY`. 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(trace): …`, `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]` (create it at the top if absent, as `cmake/CheckChangelog.cmake` requires), in `### Added` / `### Changed` subsections. + +## Review Focus + +The five inputs these specs imply but no obvious test reaches, most likely to bite first. Each one's test is in the task named. + +1. **A unit with no symbol that is not the coherent one** (`UnnamedGram`: grams with an empty symbol, in `test/rejection_tests.cpp:45`). The value must be shown in the coherent unit with its symbol (`3/1000 kg`), never as a bare number in an unnamed scale. Task 1. +2. **`Int128`'s minimum, −2^127, as a numerator.** + - `Rational::make(min, 1)` succeeds; + - `checked_negate`, `checked_abs` and `checked_reciprocal` of it are refused; + - `fraction_text` spells all 39 digits; + - the magnitude 2^127 is handled. + + Task 6. +3. **A `Rational` too wide for a 64-bit structural type** (`breakpoint(Rational)`, `band(Rational, Rational)`, `point_in`, `unit_quotient`, `rounded_square_root_in`'s units). Each site refuses as its contract says, and nothing is truncated. Task 6. +4. **The longest spelling.** A 39-digit numerator over a 39-digit denominator, and a 39-digit whole part rounded to 18 places with the `≈` marker, both fit `NumberText`. Task 5 (capacity) and Task 6 (values that reach it). +5. **Negative division, remainder and right shift in `Int128`.** They truncate toward zero, the remainder takes the dividend's sign, and `>>` is arithmetic. The native and portable routes agree, and so does the double conversion on ties. Task 4. + +## Execution + +- **Briefs and reports.** The controller writes each task's brief to `D:\formula-cpp\.superpowers\sdd\2026-10-03-int128-and-trace-units\task-N-brief.md`. The implementer writes its report beside it, as `task-N-report.md`. A report states: + - the commits; + - the test total and its delta; + - each rule in *Global Constraints* that 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`. +- **Negative tests**, where a task adds one: `test/negative/.cpp`, plus `formula_add_negative_test( "" …)` in `test/CMakeLists.txt`. Register it first with a deliberately wrong expected text and watch it fail. Then register the right text and watch it pass. Then delete the guard it pins, confirm the case compiles, and restore the guard with a plain write. + +--- + +## Lane A — units on every computed trace value (issue #2) + +### Task 1: The coherent unit's symbol at render time + +The renderer gives every dimensioned value whose unit has no symbol the coherent unit and its spelling. This is rule (c) of the trace-units spec, §4. No recording changes yet, so `#2 * #3 = 1239/500000` becomes `1239/500000 kg`, and Task 2 turns it into `1239/500 g`. + +**Files:** +- Modify: `include/formula-cpp/trace_render.hpp`: + - `coherent_unit_text` (`:2473-2526`) moves above `value_in_declared_unit` (`:1484-1520`); + - `value_in_declared_unit` changes; + - so do `rejection_value_text`'s squared branch (`:2213-2238`) and `opaque_value_text` (`:2528-2543`). +- Create: `test/trace_shown_unit_tests.cpp`, registered in `test/CMakeLists.txt`'s `add_executable(formula-cpp-tests` list (`:22`). +- Re-pin: + - every `test/*.cpp` expectation the change moves; + - `examples/display.cpp:208`, and the example pass regexes in `examples/CMakeLists.txt`; + - the ```` ```text ```` blocks of the checked guides; + - `docs/gallery.md` (regenerated); + - `docs/numeric-headroom.md` (regenerated, only if `docs.numeric-headroom` fails). + +**Interfaces:** +- Consumes: `detail::coherent_unit_text(Dimension) -> std::string`, `coherent(Dimension) -> Unit`, `view(Symbol) -> std::string_view`, `Dimension::operator*`. +- Produces: `detail::value_in_declared_unit(Step const&, std::optional const&, NumberStyle) -> std::string`. Its signature is unchanged; its rule is the new one. Task 3's test reads it. + +- [ ] **Step 1: Write the failing tests.** Create `test/trace_shown_unit_tests.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// A trace step's number is always shown with the unit it is in: borrowed from +// the operand steps where that is safe, the coherent unit's symbol otherwise, +// and nothing only for a dimensionless value. +#include +#include +#include + +#include + +#include + +namespace +{ +namespace unit = formula::unit; +using formula::Rational; +using formula::var; + +struct SampleMass: formula::Quantity +{ +}; +struct TareMass: formula::Quantity +{ +}; +struct Edge: formula::Quantity +{ +}; +struct Breadth: formula::Quantity +{ +}; + +// Grams with no symbol: a scale the number alone cannot name. +inline constexpr formula::Unit UnnamedGram { .dimension = formula::dim::Mass, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1000 }; +struct UnnamedMass: formula::Quantity +{ +}; + +template +std::string trace_text(Expression const& formulaExpression, Bound const& inputs) +{ + formula::Trace<> recorded {}; + formula::RecordingSink<> recordingSink { recorded }; + (void) formula::checked_evaluate_si(formulaExpression, inputs, recordingSink); + return formula::render_trace(recorded, { .maxSteps = 20 }); +} +} // namespace + +TEST_CASE("a product of two lengths names the coherent unit of an area", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 5 } }, + formula::Measured { Rational { 8 } }); + CHECK(trace_text(var * var, inputs) + == "1. a = 5 mm\n" + "2. b = 8 mm\n" + "3. #1 * #2 = 1/25000 m^2\n"); +} + +TEST_CASE("a value in a unit with no symbol is shown in the coherent unit, with its symbol", "[trace-render][shown-unit]") +{ + // 3 of an unnamed gram is 3/1000 kg: shown bare, the 3 would claim a scale + // nothing on the line names. + auto const inputs = formula::environment(formula::Measured { Rational { 3 } }); + CHECK(trace_text(var * Rational { 2 }, inputs).starts_with("1. m_u = 3/1000 kg\n")); +} + +TEST_CASE("a dimensionless value is still a bare number", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 413, 10 } }, + formula::Measured { Rational { 7 } }); + CHECK(trace_text(var / var, inputs) + == "1. m = 413/10 g\n" + "2. m_t = 7 g\n" + "3. #1 / #2 = 59/10\n"); +} +``` + + Add `trace_shown_unit_tests.cpp` to `add_executable(formula-cpp-tests` in `test/CMakeLists.txt`, after `trace_render_tests.cpp`. + +- [ ] **Step 2: Run them to see them fail.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "shown"`. + Expected: two of the three fail, on `1/25000` with no `m^2` and on `m_u = 3`. The dimensionless case passes. Check that ctest reports 3 tests. + +- [ ] **Step 3: Move `coherent_unit_text` above `value_in_declared_unit`.** Cut the whole function, with its doc comment, from `:2473-2526` and paste it immediately above `value_in_declared_unit`'s doc comment (`:1484`). Change only the comment's last sentence, from "For an opaque output shown in no input's unit, so that a slope in metres per second does not read as a pure number." to: "Written after every dimensioned value whose unit has no symbol, so that a slope in metres per second does not read as a pure number." + +- [ ] **Step 4: Rewrite `value_in_declared_unit`.** Replace its body and the paragraph of its comment that says the number is spelled "never padded in a unit nobody declared". The body becomes: + +```cpp + [[nodiscard]] inline std::string value_in_declared_unit(Step const& recorded, + std::optional const& storedValue, + NumberStyle numberStyle) + { + if (!storedValue.has_value()) + return std::string { NotMeasuredText }; + + // A unit with no symbol cannot say what scale its number is on. A + // dimensioned value is then shown in the coherent unit and followed by + // that unit's spelling (`coherent_unit_text`), so that no computed + // value prints as a bare number and no number is shown in a scale its + // line does not name. A dimensionless value is a bare number either + // way. + bool const spellsCoherent = view(recorded.unit.symbolText).empty() && !(recorded.dimension == dim::Scalar); + Unit const shownUnit = spellsCoherent ? coherent(recorded.dimension) : recorded.unit; + std::expected const shown = + checked_convert(*storedValue, coherent(recorded.dimension), shownUnit); + // Unreachable for a `Step` the recorder built -- it records a unit of + // the step's own dimension -- but a `Step` is a public aggregate and a + // caller may fill one in by hand. Refusing to print is the only + // honest answer: the alternative is a number in a scale the line + // claims it is not in. + if (!shown) + return not_shown_text(shown.error()); + std::expected const spelled = checked_shown_text(*shown, numberStyle, shownUnit); + if (!spelled) + return not_shown_text(spelled.error()); + + std::string valueText { spelled->view() }; + std::string const unitSymbol = spellsCoherent ? coherent_unit_text(recorded.dimension) : unit_symbol_text(shownUnit); + if (!unitSymbol.empty()) + valueText += " " + unitSymbol; + return valueText; + } +``` + + `coherent(...)` has no symbol, so `detail::is_unlabelled` still holds for it. A coherent value is still never padded, and its approximation still extends to the first significant digit. Only the suffix is new. + +- [ ] **Step 5: Squared deviations.** In `rejection_value_text`'s squared branch, replace from `Unit const shownUnit = recorded.unit;` down to the `" " + unitSymbol + "2"` append with: + +```cpp + bool const spellsCoherent = view(recorded.unit.symbolText).empty() && !(recorded.dimension == dim::Scalar); + Unit const shownUnit = spellsCoherent ? coherent(recorded.dimension) : recorded.unit; + std::expected const magnitude = + Rational::make(shownUnit.magnitudeNumerator, shownUnit.magnitudeDenominator); + std::expected const magnitudeSquared = + magnitude.has_value() ? checked_mul(*magnitude, *magnitude) : magnitude; + std::expected const shown = + magnitudeSquared.has_value() ? checked_div(si, *magnitudeSquared) : magnitudeSquared; + if (!shown) + return not_shown_text(shown.error()); + std::expected const spelled = + checked_number_text(*shown, trimmed(numberStyle), shownUnit); + if (!spelled) + return not_shown_text(spelled.error()); + std::string valueText { spelled->view() }; + // A unit with a symbol squares as the library writes squares (`g2`); + // the coherent one is spelt from its bases (`K^2`). + if (spellsCoherent) + valueText += " " + coherent_unit_text(recorded.dimension * recorded.dimension); + else if (std::string const unitSymbol = unit_symbol_text(shownUnit); !unitSymbol.empty()) + valueText += " " + unitSymbol + "2"; + return valueText; +``` + +- [ ] **Step 6: `opaque_value_text` stops appending twice.** Its body becomes the following, and its comment drops "and -- when that unit has no symbol of its own but a dimension -- followed by the coherent unit's spelling": + +```cpp + Step outputShape {}; + outputShape.dimension = dimension; + outputShape.unit = shownUnit; + return value_in_declared_unit(outputShape, storedValue, numberStyle); +``` + +- [ ] **Step 7: Run the new tests.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "shown"`. Expected: 3 of 3 pass. + +- [ ] **Step 8: Re-pin the tests.** Run Verify step 1 and collect every failing case. For each failing expectation, decide which kind of change it is: + 1. A dimensioned value that printed bare now has its coherent spelling appended. Examples: + - `#2 * #3 = 1239/500000` → `#2 * #3 = 1239/500000 kg`; + - `2. #1^2 = 36` → `2. #1^2 = 36 kg^2`; + - `sample_variance(#1) = 427/125000000` → `… kg^2`; + - `if #1 > #2 then #3 = 60000000` → `… 60000000 kg/(m s^2)`; + - `#1 - #2 = 108000000` → `… 108000000 m^2 kg/s^2`; + - `#1 * #2 = 80` (EUR) → `80 EUR`; + - a °C squared deviation → `… K^2`. + 2. A value in a unit with no symbol (`UnnamedGram` in `test/rejection_tests.cpp:45`) now shows in the coherent unit with its spelling. + + Update each expectation to the program's new text, line by line. **Any other difference is a defect: stop and report it.** That includes a dimensionless value gaining a unit, a number changing other than by the unit conversion of kind 2, and padding appearing on a coherent value. Test comments that state the old rule are rewritten in Task 3, not here. The largest files: + + | File | Lines | Notes | + |---|---|---| + | `calculation_trace_tests.cpp` | 38 | | + | `trace_render_tests.cpp` | 32 | | + | `vocabulary_tests.cpp` | 27 | | + | `retry_tests.cpp` | 21 | | + | `rejection_tests.cpp` | 21 | 9 are dimensionless and do not change | + | `precision_tests.cpp` | 13 | | + | `join_tests.cpp` | 5 | | + | `statistics_tests.cpp` | 3 | | + | `record_trace_tests.cpp` | 2 | | + | `rounded_output_tests.cpp`, `record_render_tests.cpp`, `quantity_alias_tests.cpp`, `overlay_tests.cpp` | 1 each | | + | `opaque_tests.cpp` | — | opaque outputs already showed the spelling; they must not change | + +- [ ] **Step 9: Re-pin the examples.** + - `examples/display.cpp:208` checks `tareTraceText.contains("3. #1 - #2 = 0.12\n")`. Update it to the new text of that line, which keeps `0.12` and gains ` kg`; keep its message ("a unit nobody declared is not padded"). + - Update every `formula_add_example(... PASS_REGULAR_EXPRESSION ...)` in `examples/CMakeLists.txt:14-326` that a failing `example.*` test names. + +- [ ] **Step 10: Regenerate the gallery and the checked guides.** + 1. `out\build\cl-debug\tools\gallery\formula-cpp-gallery.exe docs\gallery.md`, from `$T`, in PowerShell. + 2. For each failing `docs.-output` test, run its example (`out\build\cl-debug\examples\.exe`) and replace, in the guide's ```` ```text ```` blocks, exactly the lines that changed with the program's lines. + 3. Leave plain ```` ``` ```` blocks and prose for Task 3. + 4. If `docs.numeric-headroom` fails, build the target `formula-cpp-census-page` (`cmake --build --preset cl-debug --target formula-cpp-census-page`) and confirm with `git diff docs/numeric-headroom.md` that only the examples table changed. Rendering into the coherent unit forms no new integers, so it most likely does not fail. + +- [ ] **Step 11: Verify.** Verify step 1 must print `ALL OK`. Delta: **+3 tests**. + +- [ ] **Step 12: Commit.** + Message: `feat(trace): write the coherent unit after every computed value that has no unit of its own`. + Body: the rule from Step 4's comment, in two sentences, and that a value in a unit with no symbol is shown in the coherent unit. Then the `Signed-off-by` line. + +### Task 2: Units borrowed from the operand steps + +Rules (a), (b), negation, `abs` and the pass-through rule of the trace-units spec, §3. After this task, `#2 * #3 = 1239/500000 kg` reads `#2 * #3 = 1239/500 g`. + +**Files:** +- Modify: `include/formula-cpp/trace.hpp`: + - `detail::scaled_operand` (`:2225-2241`) gains a `BinaryNode` specialisation; + - new helpers sit beside `operand_unit_or` (`:2141-2156`): `same_scale_and_symbol`, `binary_unit_or`, `UnarySide`, `restated_unit_or`; + - `RecordingSink::produced` (`:3250-3269`) applies them. +- Test: `test/trace_shown_unit_tests.cpp`. +- Re-pin: as in Task 1, Steps 8–10. + +**Interfaces:** +- Consumes: `detail::borrowable(Unit const&)`, `detail::borrowable_for_a_point(Unit const&)`, `detail::operand_unit_or(...)`, `detail::RecordsOwnStep`, `detail::BinarySides`, `detail::StepKindOf`. +- Produces, all in `formula::detail`: + - `constexpr bool same_scale_and_symbol(Unit const&, Unit const&) noexcept`; + - `template constexpr Unit binary_unit_or(std::vector> const&, std::vector const&, Unit fallback) noexcept`; + - `template struct UnarySide`, with `using inner = Operand;` for `UnaryNode` and `AbsoluteValueNode`; + - `template constexpr Unit restated_unit_or(std::vector> const&, std::vector const&, Dimension, std::optional const&, Unit fallback) noexcept`. + +- [ ] **Step 1: Write the failing tests.** Append to `test/trace_shown_unit_tests.cpp`, inside the anonymous namespace after `UnnamedMass`: + +```cpp +struct HeavyMass: formula::Quantity +{ +}; +struct StartTemperature: formula::Quantity +{ +}; +struct EndTemperature: formula::Quantity +{ +}; +struct Strength: formula::Quantity +{ +}; + +// Grams read to four decimals: grams still, whatever the precision. +inline constexpr formula::Unit FineGram { .dimension = formula::dim::Mass, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, + .symbolText = formula::symbol("g"), + .decimals = 4 }; +struct FineMass: formula::Quantity +{ +}; + +template +formula::Trace<> recorded_trace(Expression const& formulaExpression, Bound const& inputs) +{ + formula::Trace<> recorded {}; + formula::RecordingSink<> recordingSink { recorded }; + (void) formula::checked_evaluate_si(formulaExpression, inputs, recordingSink); + return recorded; +} +``` + + Then append these test cases at the end of the file: + +```cpp +TEST_CASE("a value scaled by a pure number reads in its own unit", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 413, 10 } }); + // On the right, as the outlier-rejection limit 6 % of the mean is written. + CHECK(trace_text(Rational { 3, 50 } * var, inputs) + == "1. 3/50\n" + "2. m = 413/10 g\n" + "3. #1 * #2 = 1239/500 g\n"); + // On the left. + CHECK(trace_text(var * Rational { 3, 50 }, inputs) + == "1. m = 413/10 g\n" + "2. 3/50\n" + "3. #1 * #2 = 1239/500 g\n"); + // Divided by a pure number. + CHECK(trace_text(var / Rational { 2 }, inputs) + == "1. m = 413/10 g\n" + "2. 2\n" + "3. #1 / #2 = 413/20 g\n"); + // A pure number divided by a mass is no mass: the coherent unit, 1/kg. + CHECK(trace_text(Rational { 2 } / var, inputs) + == "1. 2\n" + "2. m = 413/10 g\n" + "3. #1 / #2 = 20000/413 1/kg\n"); +} + +TEST_CASE("a sum of two values in one unit reads in it, at the finer precision", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 413, 10 } }, + formula::Measured { Rational { 7 } }, + formula::Measured { Rational { 12345, 10000 } }); + CHECK(trace_text(var - var, inputs) + == "1. m = 413/10 g\n" + "2. m_t = 7 g\n" + "3. #1 - #2 = 343/10 g\n"); + // Grams declared at different decimals are grams: the sum is shown in + // grams, and at the finer of the two precisions. + formula::Trace<> const mixedPrecision = recorded_trace(var + var, inputs); + REQUIRE(mixedPrecision.steps.size() == 3); + CHECK(formula::view(mixedPrecision.steps[2].unit.symbolText) == "g"); + CHECK(mixedPrecision.steps[2].unit.decimals == 4); +} + +TEST_CASE("a sum of values in two units reads in the coherent unit", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 413, 10 } }, + formula::Measured { Rational { 1 } }); + CHECK(trace_text(var + var, inputs) + == "1. m = 413/10 g\n" + "2. M = 1 kg\n" + "3. #1 + #2 = 10413/10000 kg\n"); +} + +TEST_CASE("a difference of two Celsius readings is an interval in kelvin, not a reading", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 20 } }, + formula::Measured { Rational { 25 } }); + CHECK(trace_text(var - var, inputs) + == "1. T_1 = 25 \xc2\xb0" "C\n" + "2. T_0 = 20 \xc2\xb0" "C\n" + "3. #1 - #2 = 5 K\n"); +} + +TEST_CASE("a negation and an absolute value keep their operand's unit, but not an offset one", "[trace-render][shown-unit]") +{ + auto const grams = formula::environment(formula::Measured { Rational { 413, 10 } }); + CHECK(trace_text(-var, grams) + == "1. m = 413/10 g\n" + "2. -#1 = -413/10 g\n"); + CHECK(trace_text(formula::abs(-var), grams) + == "1. m = 413/10 g\n" + "2. -#1 = -413/10 g\n" + "3. abs(#2) = 413/10 g\n"); + // -(20 degC) is no reading at -20 degC: the coherent unit. + auto const celsius = formula::environment(formula::Measured { Rational { 20 } }); + CHECK(trace_text(-var, celsius) + == "1. T_0 = 20 \xc2\xb0" "C\n" + "2. -#1 = -5863/20 K\n"); +} + +TEST_CASE("a conditional reads in its chosen branch's unit, offset or not", "[trace-render][shown-unit]") +{ + auto const strengths = formula::environment(formula::Measured { Rational { 60 } }); + CHECK(trace_text(formula::when(var > formula::constant(Rational { 473, 10 }), + var, + formula::constant(Rational { 0 })), + strengths) + == "1. f = 60 MPa\n" + "2. 473/10 MPa\n" + "3. f = 60 MPa\n" + "4. if #1 > #2 then #3 = 60 MPa\n"); + // A branch's value is a point on its scale, so a Celsius branch reads in + // degrees Celsius. + auto const readings = formula::environment(formula::Measured { Rational { 20 } }, + formula::Measured { Rational { 25 } }); + CHECK(trace_text(formula::when(var > var, var, var), + readings) + == "1. T_1 = 25 \xc2\xb0" "C\n" + "2. T_0 = 20 \xc2\xb0" "C\n" + "3. T_1 = 25 \xc2\xb0" "C\n" + "4. if #1 > #2 then #3 = 25 \xc2\xb0" "C\n"); +} +``` + + The negated Celsius value: −(293.15 K) = −5863/20 K. If the evaluator refuses a negated offset reading for its own reasons, take the line the program prints, as long as it is in `K`. A conditional's exact spelling (`if #1 > #2 then #3`) is pinned at `test/trace_render_tests.cpp:520-530`; if the program's line differs only in that spelling, follow the program. + +- [ ] **Step 2: Run them to see them fail.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "shown"`. Expected: the six new cases fail, each on a coherent-unit line where a borrowed unit is expected. The three Task 1 cases still pass. + +- [ ] **Step 3: Extend `scaled_operand` to single values.** After the `ElementwiseBinaryNode` specialisation (`trace.hpp:2234-2241`), add: + +```cpp + template + inline constexpr std::optional scaled_operand> = + Op == BinaryOperator::Multiply && Left::dimension == dim::Scalar && !(Right::dimension == dim::Scalar) + ? std::optional { 1 } + : (Op == BinaryOperator::Multiply || Op == BinaryOperator::Divide) && Right::dimension == dim::Scalar + && !(Left::dimension == dim::Scalar) + ? std::optional { 0 } + : std::nullopt; +``` + + In the primary's comment, change "of @p S, an elementwise binary node," to "of @p S, a binary node -- a single value's or an elementwise one --". + +- [ ] **Step 4: Add the helpers.** Directly after `operand_unit_or` (`:2156`), and before `RecordsOwnStep`, add `same_scale_and_symbol`: + +```cpp + /// Whether @p leftUnit and @p rightUnit show values on one scale under one + /// 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==`. + [[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); + } +``` + + After `scaled_operand`'s specialisations (they must precede it), add `binary_unit_or`, `UnarySide` and `restated_unit_or`: + +```cpp + /// The unit a single value's binary step is shown in. For a product with + /// exactly one pure number, or a quotient by one, it is the other + /// operand's unit: 3/50 of a mean in grams is grams. For a sum or a + /// difference of two values shown on one scale under one name, it is + /// that unit, at the finer of their two declared precisions. Either way + /// only when each side recorded the one step claimed for it, and the + /// unit is `borrowable` and of the step's own dimension -- read off the + /// operand steps, never off a type, so that what they show is what + /// carries over. @p fallback otherwise: the coherent unit, which the + /// renderer names. + template + [[nodiscard]] constexpr Unit binary_unit_or(std::vector> const& steps, + std::vector const& operands, + Unit fallback) noexcept + { + if constexpr (!RecordsOwnStep::left> || !RecordsOwnStep::right>) + return fallback; + else + { + if (operands.size() != 2) + return fallback; + Unit const& leftUnit = steps[operands[0]].unit; + Unit const& rightUnit = steps[operands[1]].unit; + if constexpr (scaled_operand.has_value()) + { + Unit const& scaledUnit = *scaled_operand == 0 ? leftUnit : rightUnit; + return scaledUnit.dimension == N::dimension && borrowable(scaledUnit) ? scaledUnit : fallback; + } + else if constexpr (StepKindOf::value == StepKind::Add || StepKindOf::value == StepKind::Subtract) + { + if (!(leftUnit.dimension == N::dimension) || !borrowable(leftUnit) + || !same_scale_and_symbol(leftUnit, rightUnit)) + return fallback; + Unit shared = leftUnit; + shared.decimals = leftUnit.decimals < rightUnit.decimals ? rightUnit.decimals : leftUnit.decimals; + return shared; + } + else + return fallback; + } + } + + /// The one operand type of a negation or an absolute value. Undefined + /// for every other kind. + template + struct UnarySide; + + template + struct UnarySide> + { + using inner = Operand; + }; + + template + struct UnarySide> + { + using inner = Operand; + }; + + /// The unit of a step whose value restates its last claimed step's -- a + /// conditional's chosen branch, a precision limit's second pass: that + /// step's unit, when it has a symbol, is of @p dimension, and holds + /// exactly @p restatedValue. The value is a point on that step's scale, + /// so an offset unit may be shown (`borrowable_for_a_point`). Comparing + /// the values means the unit can never be claimed for a number it is not. + /// @p fallback otherwise. + template + [[nodiscard]] constexpr Unit restated_unit_or(std::vector> const& steps, + std::vector const& operands, + Dimension dimension, + std::optional const& restatedValue, + Unit fallback) noexcept + { + if (operands.empty() || !restatedValue.has_value()) + return fallback; + Step const& lastClaimed = steps[operands.back()]; + if (!(lastClaimed.dimension == dimension) || !lastClaimed.value.has_value() + || !(*lastClaimed.value == *restatedValue) || !borrowable_for_a_point(lastClaimed.unit)) + return fallback; + return lastClaimed.unit; + } +``` + + `UnaryNode`, `AbsoluteValueNode` and `UnaryOperator` must be visible at that point. `StepKindOf>` at `:1765` and `StepKindOf>` at `:1838` already use them, so they are. + +- [ ] **Step 5: Apply them in `produced`.** In `RecordingSink::produced`, replace the block at `:3265-3269` ("Which side a binary step's operand stood on") with: + +```cpp + // Which side a binary step's operand stood on, when it has one, and + // the unit it is shown in: its scaled operand's or its operands' + // shared one, when `binary_unit_or` finds one. + if constexpr (detail::StepKindOf::value == StepKind::Add || detail::StepKindOf::value == StepKind::Subtract + || detail::StepKindOf::value == StepKind::Multiply + || detail::StepKindOf::value == StepKind::Divide) + { + detail::record_operand_sides(nodeStep, _trace->steps); + nodeStep.unit = detail::binary_unit_or(_trace->steps, nodeStep.operands, nodeStep.unit); + } + + // A negation and an absolute value are on their operand's scale: + // -(3 g) is -3 g. Not on an offset one's: -(20 degC) is no reading at + // -20 degC (`borrowable`). + if constexpr (requires { typename detail::UnarySide::inner; }) + if constexpr (detail::RecordsOwnStep::inner>) + nodeStep.unit = + detail::operand_unit_or(_trace->steps, nodeStep.operands, nodeStep.dimension, nodeStep.unit); + + // A conditional's value is its chosen branch's, and a precision + // limit's is its second pass's: each reads in that step's unit. + if constexpr (detail::StepKindOf::value == StepKind::Conditional + || detail::StepKindOf::value == StepKind::PrecisionLimit) + nodeStep.unit = detail::restated_unit_or(_trace->steps, nodeStep.operands, nodeStep.dimension, nodeStep.value, + nodeStep.unit); +``` + + A precision limit's line names its last claimed operand as the step it restates (`operand_reference(recorded.operands.back())`, `trace_render.hpp:1264`), so the value comparison finds that step. + +- [ ] **Step 6: Run the new tests.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "shown"`. Expected: 9 of 9 pass. + +- [ ] **Step 7: Re-pin.** As in Task 1, Steps 8–10, with these kinds of change: + 1. A binary step now shows its scaled operand's unit: + - `#2 * #3 = 1239/500000 kg` → `#2 * #3 = 1239/500 g`; + - `#1 * #2 = 432000000 m^2 kg/s^2` → `#1 * #2 = 120 kWh`. + 2. A sum or difference in one unit now shows it: `#1 - #2 = 108000000 m^2 kg/s^2` → `#1 - #2 = 30 kWh`. + 3. A negation, an `abs` or a conditional now shows its operand's or branch's unit: `if #1 > #2 then #3 = 60000000 kg/(m s^2)` → `… = 60 MPa`. + 4. A precision limit now shows its restated step's unit (`docs/gallery.md:597`, `docs/statistics.md:328`). + 5. A step that copies its operand's unit (documented, variant, record scope, opaque output) follows that operand. + + A value now in a declared unit is styled as that unit's values are: padded to its decimals under a padded style, rounded to them under an approximating one. So `≈0.004` (`docs/display.md:199`) becomes a gram value at the gram's declared decimals. **Any other difference is a defect: stop and report it.** That includes a borrowed unit with an offset on a sum, a difference, a scaling or a negation, and a number that changed beyond the unit conversion. Then regenerate the gallery and the checked guides' text blocks, as in Task 1, Step 10. + +- [ ] **Step 8: Verify.** Verify step 1 must print `ALL OK`. Delta: **+6 tests**. + +- [ ] **Step 9: Commit.** + Message: `feat(trace): show a computed value in the unit its operands are shown in, where that is safe`. + Body: one sentence each for scaling, sums and differences, negation and `abs`, and restated values; that an offset unit is never borrowed for a sum, difference, scaling or negation. Then the `Signed-off-by` line. + +### Task 3: Every printed value re-derived, and the documentation of the rule + +This task covers acceptance criterion 4 of issue #2: no step prints a number in a unit other than the one written after it. It is proved over whole traces. Then every guide, doc comment and changelog line that states the old rule is brought up to date. + +**Files:** +- Modify: `test/trace_shown_unit_tests.cpp`: the walker test. +- Modify, the library's doc comments: + - `include/formula-cpp/trace.hpp`: the `Step::unit` comment (`:970-997`) and the recording comment in `produced` (`:3093-3117`); + - `include/formula-cpp/trace_render.hpp`: `:25-39`, `:104-123`, `:3342-3350`, `:3398-3404`. +- Modify, the guides' prose and their plain ```` ``` ```` blocks: + - `docs/tracing.md:286-345`, `:439`, `:559`; + - `docs/display.md:161-218`, `:359-373`, with `examples/display.cpp` (see Step 5); + - `docs/dimensions.md:447-455`, `docs/statistics.md:110-112`, `docs/series.md:106-113`, `docs/calculations.md:597-599`; + - `docs/expressions.md:601-612`, `docs/lookup-tables.md:717-752`, `docs/rounding-and-conditionals.md:228-264`; + - `docs/methods-and-overlays.md:86-90`, `docs/constraints.md`, `docs/records.md`. +- Modify: `tools/gallery/main.cpp:954`, the prose it writes into `docs/gallery.md:455`. Then regenerate `docs/gallery.md`. +- Modify: the test comments that state the old rule, e.g. `test/trace_render_tests.cpp:101-104`, and any test name that says a computed value has no unit. +- Modify: `CHANGELOG.md`. + +**Interfaces:** +- Consumes, all from Tasks 1–2: `formula::detail::value_in_declared_unit`, `formula::detail::coherent_unit_text`, `formula::detail::unit_symbol_text`, `formula::checked_convert`, `formula::coherent`. +- Produces: nothing new. + +- [ ] **Step 1: Write the walker test.** Append to `test/trace_shown_unit_tests.cpp`. Add `#include `, `#include `, `#include ` and `#include ` to the includes. In the anonymous namespace: + +```cpp +/// The decimal digits at the start of @p spelled, as an exact number, and +/// @p spelled advanced past them; nothing when it does not start with one. +std::optional take_whole(std::string_view& spelled) +{ + if (spelled.empty() || spelled.front() < '0' || spelled.front() > '9') + return std::nullopt; + Rational parsed {}; + while (!spelled.empty() && spelled.front() >= '0' && spelled.front() <= '9') + { + std::expected const shifted = formula::checked_mul(parsed, Rational { 10 }); + if (!shifted) + return std::nullopt; + std::expected const added = + formula::checked_add(*shifted, Rational { spelled.front() - '0' }); + if (!added) + return std::nullopt; + parsed = *added; + spelled.remove_prefix(1); + } + return parsed; +} + +/// A value as a trace writes it in the fraction style: `-a/b unit`, `a`, +/// `a/b`, each with or without a unit after a space. +struct ShownValue +{ + Rational shownNumber; + std::string_view unitText; +}; + +std::optional parse_shown(std::string_view spelled) +{ + bool const negative = spelled.starts_with('-'); + if (negative) + spelled.remove_prefix(1); + std::optional const wholeNumber = take_whole(spelled); + if (!wholeNumber) + return std::nullopt; + Rational parsed = *wholeNumber; + if (spelled.starts_with('/')) + { + spelled.remove_prefix(1); + std::optional const below = take_whole(spelled); + if (!below) + return std::nullopt; + std::expected const divided = formula::checked_div(parsed, *below); + if (!divided) + return std::nullopt; + parsed = *divided; + } + if (negative) + { + std::expected const negated = formula::checked_negate(parsed); + if (!negated) + return std::nullopt; + parsed = *negated; + } + if (spelled.starts_with(' ')) + spelled.remove_prefix(1); + else if (!spelled.empty()) + return std::nullopt; + return ShownValue { parsed, spelled }; +} + +/// For every step of @p recorded that holds a value: the text after the +/// number names the step's own unit, the coherent unit, or -- only for a +/// dimensionless step -- nothing; and the number, read back from that unit +/// into the coherent one, is exactly the value recorded. The unit is taken +/// from the text, not from the rule that chose it, so a value written in one +/// scale and labelled with another fails here. +void check_each_value_is_in_the_unit_written_after_it(formula::Trace<> const& recorded) +{ + std::size_t checkedSteps = 0; + for (formula::Step const& recordedStep: recorded.steps) + { + if (!recordedStep.value.has_value() || recordedStep.error.has_value()) + continue; + std::string const shownText = + formula::detail::value_in_declared_unit(recordedStep, recordedStep.value, formula::NumberStyle::fraction()); + INFO("step shown as: " << shownText); + std::optional const parsed = parse_shown(shownText); + REQUIRE(parsed.has_value()); + formula::Unit const coherentUnit = formula::coherent(recordedStep.dimension); + std::optional namedUnit; + if (!parsed->unitText.empty() && parsed->unitText == formula::detail::unit_symbol_text(recordedStep.unit)) + namedUnit = recordedStep.unit; + else if (!parsed->unitText.empty() && parsed->unitText == formula::detail::coherent_unit_text(recordedStep.dimension)) + namedUnit = coherentUnit; + else if (parsed->unitText.empty() && recordedStep.dimension == formula::dim::Scalar) + namedUnit = recordedStep.unit; + REQUIRE(namedUnit.has_value()); + std::expected const backInCoherent = + formula::checked_convert(parsed->shownNumber, *namedUnit, coherentUnit); + REQUIRE(backInCoherent.has_value()); + CHECK(*backInCoherent == *recordedStep.value); + ++checkedSteps; + } + CHECK(checkedSteps > 0); +} +``` + + Then the test case: + +```cpp +TEST_CASE("every value a trace shows is in the unit written after it", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 413, 10 } }, + formula::Measured { Rational { 7 } }, + formula::Measured { Rational { 1 } }, + formula::Measured { Rational { 5 } }, + formula::Measured { Rational { 8 } }, + formula::Measured { Rational { 20 } }, + formula::Measured { Rational { 25 } }, + formula::Measured { Rational { 60 } }, + formula::Measured { Rational { 3 } }, + formula::Measured { Rational { 12345, 10000 } }); + // A power, a quotient in the coherent unit, scaling, sums in one unit and + // in two, an offset difference, negations of both kinds, an absolute + // value, conditionals over both kinds of branch, and a unit with no symbol. + check_each_value_is_in_the_unit_written_after_it( + recorded_trace(formula::pow<2>(var) / var + var, inputs)); + check_each_value_is_in_the_unit_written_after_it( + recorded_trace(formula::abs(var - var) * Rational { 3, 50 } + var, inputs)); + check_each_value_is_in_the_unit_written_after_it(recorded_trace( + formula::when(var > var, var - var, + -var), + inputs)); + check_each_value_is_in_the_unit_written_after_it(recorded_trace( + formula::when(var > formula::constant(Rational { 473, 10 }), var / Rational { 2 }, + -var), + inputs)); + check_each_value_is_in_the_unit_written_after_it(recorded_trace(var * Rational { 2 } - var, inputs)); +} +``` + + The second conditional's branches are dimensionally the same (MPa); the first's both read in kelvin or Celsius. If `formula::pow` is spelled differently, use the form in `test/trace_render_tests.cpp:89` (`formula::pow<2>(var)`). + +- [ ] **Step 2: Run it.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "shown"`. Expected: 10 of 10 pass. + + Then prove it can fail. Temporarily change Task 1's `spellsCoherent ? coherent(recorded.dimension) : recorded.unit` to `recorded.unit` in `value_in_declared_unit`. The new test must fail on the `UnnamedMass` formula: `3` is written, `kg` is named, and `3 kg` is not the recorded value. Restore the line with a plain write and re-run it to green. + +- [ ] **Step 3: The doc comments.** + - **`Step::unit`** (`trace.hpp:970-997`). State the rule: + - a variable, constant or rounding shows its declared unit; + - a value scaled by a pure number shows its operand's unit, and so does a sum or difference on one scale under one name; + - a negation and an absolute value show their operand's unit; + - a conditional and a precision limit show the unit of the step they restate; + - an offset unit is never borrowed for a sum, difference, scaling or negation; + - everything else is the coherent unit, which the renderer writes after the number, spelt from its bases. + + `docs/tracing.md:308-325` quotes this comment; quote the new one there word for word. + - **The comment in `produced`** (`:3093-3117`) says "Anything computed has no declared unit, so the coherent one is the truthful answer". Add: "-- until the rules below borrow one from the operand steps". + - **`trace_render.hpp:25-39` and `:104-123`** (`TraceRenderOptions::numbers`). They say a value in no declared unit is "never padded" and prints bare. Keep the styling sentence; it is still true of the coherent unit. Replace "bare" with "followed by the coherent unit's spelling, `kg/m^3`". + - **`trace_render.hpp:3342-3350` and `:3398-3404`.** Bring the derivation example up to date with a real line from the gallery. + +- [ ] **Step 4: The guides.** Change each place listed under *Files* so that every sentence and every plain ```` ``` ```` block matches what the library now prints. A plain block that mirrors an example program's output is copied from the program. A hand-written block is rewritten by the rules in Step 3, and each number keeps its value in the unit now written. Where a guide states the old rule, e.g. `docs/tracing.md` "A computed value is shown in the coherent unit, with no symbol", it states the new one. `docs/statistics.md`'s outlier-rejection walkthrough now reads `#2 * #3 = 1239/500 g`; its prose says the limit is 3/50 of the mean, in grams. + +- [ ] **Step 5: The display guide's section "A value in a unit nobody declared"** (`docs/display.md:161-218`, `:359-373`). Its dish and tare examples now borrow grams, so they no longer show such a value. + - Change `examples/display.cpp`'s demonstration (the code that builds `tareTraceText`, around `:190-210`) so that one step is still in the coherent unit, e.g. an area from two lengths in mm (`m^2`). + - Keep the check at `:208` asserting that such a value is not padded, now on the area's line. + - Rewrite the section around that output: a value in the coherent unit carries its base-unit spelling, is never padded, and approximates to its first significant digit. + - `docs.display-output` and `docs.display-snippets` hold the guide to the example. Every ```` ```cpp ```` block quoting the changed code must be consecutive source lines of it. + +- [ ] **Step 6: The gallery generator's prose.** `tools/gallery/main.cpp:954` writes the sentence that becomes `docs/gallery.md:455`. Change it to the new rule, then regenerate: `out\build\cl-debug\tools\gallery\formula-cpp-gallery.exe docs\gallery.md`. + +- [ ] **Step 7: CHANGELOG.** Under `## [Unreleased]` → `### Changed`, add: + +```markdown +- **Every computed value in a trace shows a unit.** A value scaled by a pure number, and a sum or difference of + values shown in one unit, read in that unit: the outlier-rejection limit `#2 * #3 = 1239/500000` is now + `#2 * #3 = 1239/500 g`. A negation and an absolute value read in their operand's unit, and a conditional and a + precision limit in the unit of the step they restate. An offset unit is never borrowed for a sum, difference, + scaling or negation: the difference of two Celsius readings reads in `K`. Any other dimensioned value is shown in + the coherent unit, followed by its spelling from the base units (`427/125000000 kg^2`, `60000000 kg/(m s^2)`), and + so is a value in a unit that has no symbol. A dimensionless value is still a bare number. A trace text pinned in + a test changes wherever it showed a dimensioned value bare. +``` + +- [ ] **Step 8: Verify.** Verify step 1 must print `ALL OK`. Delta: **+1 test**. Then grep the guides for bare dimensioned values that remain: `Select-String -Path docs\*.md -Pattern '#\d+ [-+*/] #\d+ = [-0-9/≈.]+$'`. Every hit must be a dimensionless step; name each in the report. + +- [ ] **Step 9: Commit.** + Message: `docs(trace): state the shown-unit rule, and prove every value is in the unit written after it`. + Then the `Signed-off-by` line. + +--- + +## Lane B — a 128-bit `Rational` (issue #1) + +Lane B works in its own worktree. Before Task 4 the controller creates it from the plan commit: + +```powershell +git -C D:\formula-cpp\.claude\worktrees\int128-and-trace-units worktree add D:\formula-cpp\.claude\worktrees\int128-core -b feature/int128-core +``` + +### Task 4: `formula::Int128` + +A signed 128-bit integer with one API on every compiler, its `std::formatter`, and its tests. Nothing uses it yet. + +**Files:** +- Create: `include/formula-cpp/int128.hpp`. +- Modify: `include/formula-cpp/format.hpp`: `#include `, and `std::formatter` beside `formatter` (`:576-603`). +- Modify: `CMakeLists.txt`: `include/formula-cpp/int128.hpp` in the install `FILE_SET` (`:41-106`), in alphabetical order. +- Modify: `test/consumer_globals_tests.cpp`: include the header and use it. Check the result in `test/consumer_globals_run_tests.cpp`, following that file's pattern. +- Create: `test/int128_tests.cpp`, registered in `test/CMakeLists.txt`'s `add_executable(formula-cpp-tests` list after `checked_int_tests.cpp`. +- Create: `test/negative/int128_format_spec_not_understood.cpp`, registered in `test/CMakeLists.txt` beside `format_spec_not_understood`, with the same expected text. + +**Interfaces:** +- Produces, in `formula`: + - `class Int128` with: + - `Int128()`; + - the implicit `template Int128(T)`, for every type but `bool` and nothing wider than 64 bits; + - `static from_words(std::uint64_t high, std::uint64_t low)`, `high_word()`, `low_word()`; + - `is_negative()`, `fits_int64()`, `to_int64() -> std::optional`, `to_uint64() -> std::optional`, `to_double()`; + - `==` and `<=>`; + - hidden friends `+ - * / %`, unary `- +`, `<< >>` (by `int`); + - compound assignments, and `++`/`--` in both forms. + - `std::numeric_limits` and `std::formatter`. +- Produces, in `formula::detail`: + - `struct UInt128 { std::uint64_t highWord; std::uint64_t lowWord; }`, with `from_u64`, `fits_u64`, `is_zero`, `bit_width`, `==` and `<=>`; + - `struct UInt128Division { UInt128 quotient; UInt128 remainder; }`; + - `namespace portable { add, subtract, multiply_words, multiply, multiply_checked, shift_left, shift_right, divide }`; + - `u128_add`, `u128_sub`, `u128_mul_words`, `u128_mul`, `u128_mul_checked`, `u128_divmod`, `u128_countr_zero`, `u128_gcd`, `u128_isqrt`, `u128_pow10`; + - `struct DecimalSpelling { char characters[39]; int length; }` and `u128_decimal(UInt128)`; + - `magnitude(Int128) -> UInt128` and `signed_from_magnitude(UInt128, bool) -> Int128`; + - with native support, `NativeUInt128`, `to_native` and `from_native`; + - the macro `FORMULA_NATIVE_INT128`, 0 or 1. + +- [ ] **Step 1: Write the failing tests.** Create `test/int128_tests.cpp`. Every expected value below was computed with Python's arbitrary-precision integers and reduced to 128-bit two's complement. + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// +// formula::Int128: two's complement arithmetic on 128 bits, the same at +// compile time and at run time, and the same through the compiler's own +// 128-bit integer and through the portable code. Expected values were +// computed with Python's integers. +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using formula::Int128; +using formula::detail::UInt128; + +constexpr Int128 words(std::uint64_t highWord, std::uint64_t lowWord) noexcept +{ + return Int128::from_words(highWord, lowWord); +} + +constexpr UInt128 unsigned_words(std::uint64_t highWord, std::uint64_t lowWord) noexcept +{ + return UInt128 { highWord, lowWord }; +} + +struct ArithmeticCase +{ + Int128 leftOperand; + Int128 rightOperand; + Int128 added; + Int128 subtracted; + Int128 multiplied; + Int128 divided; + Int128 remaining; +}; + +// Sums, differences and products wrap; quotients round toward zero; a +// remainder takes the dividend's sign. +constexpr std::array arithmeticCases { { + { words(0x7fffffffffffffff, 0xffffffffffffffff), words(0x0000000000000000, 0x0000000000000003), + words(0x8000000000000000, 0x0000000000000002), words(0x7fffffffffffffff, 0xfffffffffffffffc), + words(0x7fffffffffffffff, 0xfffffffffffffffd), words(0x2aaaaaaaaaaaaaaa, 0xaaaaaaaaaaaaaaaa), + words(0x0000000000000000, 0x0000000000000001) }, + { words(0x8000000000000000, 0x0000000000000000), words(0x0000000000000000, 0x0000000000000007), + words(0x8000000000000000, 0x0000000000000007), words(0x7fffffffffffffff, 0xfffffffffffffff9), + words(0x8000000000000000, 0x0000000000000000), words(0xedb6db6db6db6db6, 0xdb6db6db6db6db6e), + words(0xffffffffffffffff, 0xfffffffffffffffe) }, + { words(0x0000000000000001, 0x0000000000003039), words(0x0000000000000000, 0xffffffffffffffff), + words(0x0000000000000002, 0x0000000000003038), words(0x0000000000000000, 0x000000000000303a), + words(0x0000000000003037, 0xffffffffffffcfc7), words(0x0000000000000000, 0x0000000000000001), + words(0x0000000000000000, 0x000000000000303a) }, + { words(0xfffffffe7116f009, 0x3c8c1f11b1c0f52e), words(0x0000000000000000, 0x0db4da5f49f8b478), + words(0xfffffffe7116f009, 0x4a40f970fbb9a9a6), words(0xfffffffe7116f009, 0x2ed744b267c840b6), + words(0x4b38a08ad7aa0090, 0x0e176230a1674590), words(0xffffffffffffffff, 0xffffffe2e56b6274), + words(0xffffffffffffffff, 0xf326734031d13ece) }, + { words(0x7fffffffffffffff, 0xffffffffffffffff), words(0x8000000000000000, 0x0000000000000001), + words(0x0000000000000000, 0x0000000000000000), words(0xffffffffffffffff, 0xfffffffffffffffe), + words(0xffffffffffffffff, 0xffffffffffffffff), words(0xffffffffffffffff, 0xffffffffffffffff), + words(0x0000000000000000, 0x0000000000000000) }, + { words(0x0000001000000000, 0x0000000000000001), words(0xffffffffffffffff, 0xffffffeffffffffb), + words(0x0000000fffffffff, 0xffffffeffffffffc), words(0x0000001000000000, 0x0000001000000006), + words(0xffffffafffffffff, 0xffffffeffffffffb), words(0xffffffffffffffff, 0x0000000050000000), + words(0x0000000000000000, 0x0000000190000001) }, + { words(0xffffffffffffffff, 0xfffffffffffffffb), words(0x0000000000000000, 0x0000000000000003), + words(0xffffffffffffffff, 0xfffffffffffffffe), words(0xffffffffffffffff, 0xfffffffffffffff8), + words(0xffffffffffffffff, 0xfffffffffffffff1), words(0xffffffffffffffff, 0xffffffffffffffff), + words(0xffffffffffffffff, 0xfffffffffffffffe) }, + { words(0x0000000000000000, 0x0000000000000005), words(0xffffffffffffffff, 0xfffffffffffffffd), + words(0x0000000000000000, 0x0000000000000002), words(0x0000000000000000, 0x0000000000000008), + words(0xffffffffffffffff, 0xfffffffffffffff1), words(0xffffffffffffffff, 0xffffffffffffffff), + words(0x0000000000000000, 0x0000000000000002) }, + { words(0xffffffffffffffff, 0x0000000000000000), words(0xffffffffffffffff, 0x8000000000000000), + words(0xfffffffffffffffe, 0x8000000000000000), words(0xffffffffffffffff, 0x8000000000000000), + words(0x8000000000000000, 0x0000000000000000), words(0x0000000000000000, 0x0000000000000002), + words(0x0000000000000000, 0x0000000000000000) }, +} }; + +constexpr bool every_arithmetic_case_holds() noexcept +{ + for (ArithmeticCase const& checked: arithmeticCases) + { + if (!(checked.leftOperand + checked.rightOperand == checked.added) + || !(checked.leftOperand - checked.rightOperand == checked.subtracted) + || !(checked.leftOperand * checked.rightOperand == checked.multiplied) + || !(checked.leftOperand / checked.rightOperand == checked.divided) + || !(checked.leftOperand % checked.rightOperand == checked.remaining)) + return false; + } + return true; +} + +struct GcdCase +{ + UInt128 leftOperand; + UInt128 rightOperand; + UInt128 common; +}; + +constexpr std::array gcdCases { { + // 2^127 - 1 and 2^64 - 1: 2^gcd(127, 64) - 1 = 1. + { unsigned_words(0x7fffffffffffffff, 0xffffffffffffffff), unsigned_words(0, 0xffffffffffffffff), unsigned_words(0, 1) }, + // 2^90 3^20 and 2^80 3^25 5: 2^80 3^20. + { unsigned_words(0x033f506e44000000, 0), unsigned_words(0x03da5faed52f0000, 0), unsigned_words(0x0000cfd41b910000, 0) }, + { unsigned_words(0x7fffffffffffffff, 0xffffffffffffffff), unsigned_words(0x7fffffffffffffff, 0xffffffffffffffff), + unsigned_words(0x7fffffffffffffff, 0xffffffffffffffff) }, + { unsigned_words(0, 0), unsigned_words(0x0000001000000000, 0), unsigned_words(0x0000001000000000, 0) }, + // 6 * 10^30 and 4 * 10^25 + 2. + { unsigned_words(0x0000004bbb0bace1, 0xa6bd937d80000000), unsigned_words(0x0000000000211654, 0x5850052128000002), + unsigned_words(0, 6) }, + { unsigned_words(7, 0), unsigned_words(0x4d, 0), unsigned_words(7, 0) }, +} }; +} // namespace + +TEST_CASE("Int128 adds, subtracts, multiplies and divides as a 128-bit two's complement integer", "[int128]") +{ + for (ArithmeticCase const& checked: arithmeticCases) + { + CHECK(checked.leftOperand + checked.rightOperand == checked.added); + CHECK(checked.leftOperand - checked.rightOperand == checked.subtracted); + CHECK(checked.leftOperand * checked.rightOperand == checked.multiplied); + CHECK(checked.leftOperand / checked.rightOperand == checked.divided); + CHECK(checked.leftOperand % checked.rightOperand == checked.remaining); + } +} + +TEST_CASE("Int128 computes the same at compile time", "[int128]") +{ + STATIC_REQUIRE(every_arithmetic_case_holds()); +} + +TEST_CASE("Int128 converts from every built-in integer exactly, and to none without asking", "[int128]") +{ + STATIC_REQUIRE(Int128 { -5 } == words(~std::uint64_t { 0 }, ~std::uint64_t { 0 } - 4)); + STATIC_REQUIRE(Int128 { std::numeric_limits::max() } == words(0, ~std::uint64_t { 0 })); + STATIC_REQUIRE(Int128 { std::numeric_limits::min() } == words(~std::uint64_t { 0 }, std::uint64_t { 1 } << 63)); + STATIC_REQUIRE_FALSE(std::is_constructible_v); + // No conversion to a built-in integer, implicit or explicit: narrowing is + // to_int64() or to_uint64(), which say when the value does not fit. + STATIC_REQUIRE_FALSE(std::is_convertible_v); + STATIC_REQUIRE_FALSE(std::is_constructible_v); + STATIC_REQUIRE_FALSE(std::is_constructible_v); + STATIC_REQUIRE(Int128 { -1 }.to_int64() == std::optional { -1 }); + STATIC_REQUIRE(Int128 { std::numeric_limits::max() }.to_int64() == std::nullopt); + STATIC_REQUIRE(Int128 { std::numeric_limits::max() }.to_uint64() + == std::optional { std::numeric_limits::max() }); + STATIC_REQUIRE(Int128 { -1 }.to_uint64() == std::nullopt); + STATIC_REQUIRE(words(1, 0).to_int64() == std::nullopt); +} + +TEST_CASE("Int128 orders as a signed integer and shifts arithmetically", "[int128]") +{ + constexpr Int128 smallest = std::numeric_limits::min(); + constexpr Int128 largest = std::numeric_limits::max(); + STATIC_REQUIRE(smallest == words(std::uint64_t { 1 } << 63, 0)); + STATIC_REQUIRE(largest == words(~(std::uint64_t { 1 } << 63), ~std::uint64_t { 0 })); + STATIC_REQUIRE(smallest < Int128 { -1 }); + STATIC_REQUIRE(Int128 { -1 } < Int128 { 0 }); + STATIC_REQUIRE(words(0, ~std::uint64_t { 0 }) < words(1, 0)); + STATIC_REQUIRE(0 < largest); + STATIC_REQUIRE((Int128 { -8 } >> 1) == Int128 { -4 }); + STATIC_REQUIRE((Int128 { -1 } >> 127) == Int128 { -1 }); + STATIC_REQUIRE((smallest >> 64) == words(~std::uint64_t { 0 }, std::uint64_t { 1 } << 63)); + STATIC_REQUIRE((Int128 { 1 } << 127) == smallest); + STATIC_REQUIRE((Int128 { 3 } << 64) == words(3, 0)); + STATIC_REQUIRE(-smallest == smallest); + STATIC_REQUIRE(std::numeric_limits::digits == 127); + STATIC_REQUIRE(std::numeric_limits::is_signed); +} + +TEST_CASE("Int128 converts to the nearest double, ties to even", "[int128]") +{ + CHECK(words(0x7fffffffffffffff, 0xffffffffffffffff).to_double() == 0x1p+127); + CHECK(words(0x8000000000000000, 0).to_double() == -0x1p+127); + // 2^64 + 2^11 is half way between two doubles: to even, 2^64. + CHECK(words(1, 0x800).to_double() == 0x1p+64); + // One more and it is past half way. + CHECK(words(1, 0x801).to_double() == 0x1.0000000000001p+64); + // 2^64 + 3 * 2^11 is half way again: to even, upwards this time. + CHECK(words(1, 0x1800).to_double() == 0x1.0000000000002p+64); + CHECK(words(0xffffffefffffffff, 0xffff800000000000).to_double() == -0x1p+100); + CHECK(words(0xffffffefffffffff, 0xffff7fffffffffff).to_double() == -0x1.0000000000001p+100); + CHECK(Int128 { -7 }.to_double() == -7.0); +} + +TEST_CASE("the 128-bit greatest common divisor, square root and powers of ten", "[int128]") +{ + for (GcdCase const& checked: gcdCases) + { + CHECK(formula::detail::u128_gcd(checked.leftOperand, checked.rightOperand) == checked.common); + CHECK(formula::detail::u128_gcd(checked.rightOperand, checked.leftOperand) == checked.common); + } + CHECK(formula::detail::u128_isqrt(unsigned_words(~std::uint64_t { 0 }, ~std::uint64_t { 0 })) == ~std::uint64_t { 0 }); + CHECK(formula::detail::u128_isqrt(unsigned_words(std::uint64_t { 1 } << 63, 0)) == 0xb504f333f9de6484); + CHECK(formula::detail::u128_isqrt(unsigned_words(1, 0)) == std::uint64_t { 1 } << 32); + // (2^64 - 1)^2 and one below it. + CHECK(formula::detail::u128_isqrt(unsigned_words(0xfffffffffffffffe, 1)) == ~std::uint64_t { 0 }); + CHECK(formula::detail::u128_isqrt(unsigned_words(0xfffffffffffffffe, 0)) == 0xfffffffffffffffe); + CHECK(formula::detail::u128_pow10(38) == unsigned_words(0x4b3b4ca85a86c47a, 0x098a224000000000)); + CHECK(formula::detail::u128_pow10(39) == std::nullopt); + CHECK(formula::detail::u128_pow10(-1) == std::nullopt); +} + +TEST_CASE("Int128 formats as its decimal digits", "[int128][format]") +{ + CHECK(std::format("{}", Int128 { 0 }) == "0"); + CHECK(std::format("{}", Int128 { -1 }) == "-1"); + CHECK(std::format("{}", std::numeric_limits::max()) == "170141183460469231731687303715884105727"); + CHECK(std::format("{}", std::numeric_limits::min()) == "-170141183460469231731687303715884105728"); + CHECK(std::format("{}", words(1, 0)) == "18446744073709551616"); + CHECK(std::format("{}", words(0x4b3b4ca85a86c47a, 0x098a224000000000)) == "100000000000000000000000000000000000000"); +} + +TEST_CASE("the 128-bit division, product and greatest common divisor keep their defining identities", "[int128]") +{ + // splitmix64, seeded: a mix of 64-bit, 128-bit and boundary operands. + std::uint64_t state = 20261003; + auto const nextWord = [&state] { + state += 0x9E3779B97F4A7C15ULL; + std::uint64_t mixed = state; + mixed = (mixed ^ (mixed >> 30)) * 0xBF58476D1CE4E5B9ULL; + mixed = (mixed ^ (mixed >> 27)) * 0x94D049BB133111EBULL; + return mixed ^ (mixed >> 31); + }; + for (int drawn = 0; drawn < 4000; ++drawn) + { + std::uint64_t const shape = nextWord() % 4; + UInt128 const dividend { shape == 0 ? std::uint64_t { 0 } : nextWord(), nextWord() }; + UInt128 const divisor { shape < 2 ? std::uint64_t { 0 } : nextWord() >> (nextWord() % 64), nextWord() | 1U }; + formula::detail::UInt128Division const split = formula::detail::u128_divmod(dividend, divisor); + CHECK(split.remainder < divisor); + std::optional const recombined = formula::detail::u128_mul_checked(split.quotient, divisor); + REQUIRE(recombined.has_value()); + CHECK(formula::detail::u128_add(*recombined, split.remainder) == dividend); + UInt128 const common = formula::detail::u128_gcd(dividend, divisor); + CHECK(formula::detail::u128_divmod(dividend, common).remainder.is_zero()); + CHECK(formula::detail::u128_divmod(divisor, common).remainder.is_zero()); + CHECK(formula::detail::u128_gcd(formula::detail::u128_divmod(dividend, common).quotient, + formula::detail::u128_divmod(divisor, common).quotient) + == UInt128::from_u64(1)); + } +} + +#if FORMULA_NATIVE_INT128 +TEST_CASE("the portable 128-bit arithmetic agrees with the compiler's own", "[int128]") +{ + using namespace formula::detail; + std::uint64_t state = 1272026; + auto const nextWord = [&state] { + state += 0x9E3779B97F4A7C15ULL; + std::uint64_t mixed = state; + mixed = (mixed ^ (mixed >> 30)) * 0xBF58476D1CE4E5B9ULL; + mixed = (mixed ^ (mixed >> 27)) * 0x94D049BB133111EBULL; + return mixed ^ (mixed >> 31); + }; + for (int drawn = 0; drawn < 4000; ++drawn) + { + UInt128 const leftOperand { nextWord() % 3 == 0 ? std::uint64_t { 0 } : nextWord(), nextWord() }; + UInt128 const rightOperand { nextWord() % 3 == 0 ? std::uint64_t { 0 } : nextWord() >> (nextWord() % 64), + nextWord() | 1U }; + NativeUInt128 const nativeLeft = to_native(leftOperand); + NativeUInt128 const nativeRight = to_native(rightOperand); + CHECK(portable::multiply(leftOperand, rightOperand) == from_native(nativeLeft * nativeRight)); + CHECK(portable::divide(leftOperand, rightOperand).quotient == from_native(nativeLeft / nativeRight)); + CHECK(portable::divide(leftOperand, rightOperand).remainder == from_native(nativeLeft % nativeRight)); + NativeUInt128 nativeProduct = 0; + bool const nativeOverflowed = __builtin_mul_overflow(nativeLeft, nativeRight, &nativeProduct); + std::optional const portableProduct = portable::multiply_checked(leftOperand, rightOperand); + CHECK(portableProduct.has_value() == !nativeOverflowed); + if (portableProduct.has_value()) + CHECK(*portableProduct == from_native(nativeProduct)); + CHECK(portable::multiply_words(leftOperand.lowWord, rightOperand.lowWord) + == from_native(static_cast(leftOperand.lowWord) * rightOperand.lowWord)); + } +} +#endif +``` + + Create `test/negative/int128_format_spec_not_understood.cpp`, matching `test/negative/format_spec_not_understood.cpp`'s shape: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// An Int128 is written as its decimal digits; any spec but the empty one is +// refused where the format string is written. +#include + +#include +#include + +std::string probe() +{ + return std::format("{:x}", formula::Int128 { 255 }); +} +``` + + Register it with `formula_add_negative_test(int128_format_spec_not_understood "formula_number_format_spec_not_understood" …)`, copying the arguments of `format_spec_not_understood`'s registration. + +- [ ] **Step 2: Run them to see them fail.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "int128"`. + Expected: a build failure, because `formula-cpp/int128.hpp` does not exist. + +- [ ] **Step 3: Write `include/formula-cpp/int128.hpp`.** + +```cpp +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// `formula::Int128`, the signed 128-bit integer `Rational` stores its +/// numerator and denominator in. +/// +/// One class, with one API on every compiler. It is held as two 64-bit words +/// in two's complement everywhere. Where the compiler has a 128-bit integer +/// -- GCC, Clang and AppleClang -- multiplication, division and the overflow +/// check are carried out in it; elsewhere, in portable `constexpr` code on the +/// two words. That is cl, and clang-cl too: it accepts `__int128`, but +/// dividing one calls compiler-rt's `__divti3`, which the MSVC linker does +/// not supply. Both routes give the same bits for every operation, at compile +/// time and at run time, so a number is the same on every compiler. +/// +/// Division, the remainder and the greatest common divisor take a 64-bit +/// route whenever their operands fit 64 bits, which nearly every number a +/// formula forms does. +/// +/// **No conversion to a built-in integer type**, implicit or explicit. +/// Narrowing is spelled `to_int64()` and `to_uint64()`, which answer +/// `std::nullopt` when the value does not fit, so that no code can cut a +/// 128-bit value down to 64 bits without saying what happens when it does +/// not fit. +/// +/// The operators behave as a built-in signed integer's do, except that none +/// is undefined: a sum, difference or product that does not fit wraps +/// around, and a division by zero is a precondition violation. The library's +/// own arithmetic never relies on wrapping; it uses the checked forms in +/// `detail/checked_int.hpp`. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__SIZEOF_INT128__) && !defined(_MSC_VER) + /// 1 where the compiler's own 128-bit integer carries `Int128`'s + /// multiplication and division, 0 where the portable code does. Internal: + /// not part of the library's contract. + #define FORMULA_NATIVE_INT128 1 +#else + #define FORMULA_NATIVE_INT128 0 +#endif + +namespace formula::detail +{ + +/// An unsigned 128-bit integer, as two 64-bit words, the more significant +/// first so that the defaulted comparisons order it numerically. It holds +/// magnitudes: 2^127, the magnitude of `Int128`'s minimum, has no signed +/// counterpart. +struct UInt128 +{ + /// Bits 64 to 127. + std::uint64_t highWord = 0; + /// Bits 0 to 63. + std::uint64_t lowWord = 0; + + /// @p whole, widened. + [[nodiscard]] static constexpr UInt128 from_u64(std::uint64_t whole) noexcept { return UInt128 { 0, whole }; } + + /// Whether it fits 64 bits. + [[nodiscard]] constexpr bool fits_u64() const noexcept { return highWord == 0; } + + /// Whether it is zero. + [[nodiscard]] constexpr bool is_zero() const noexcept { return highWord == 0 && lowWord == 0; } + + /// How many bits writing it takes: 0 for zero, 128 at most. + [[nodiscard]] constexpr int bit_width() const noexcept + { + return highWord != 0 ? 64 + static_cast(std::bit_width(highWord)) : static_cast(std::bit_width(lowWord)); + } + + /// Numeric equality. + [[nodiscard]] constexpr bool operator==(UInt128 const&) const noexcept = default; + /// Numeric order: the more significant word first. + [[nodiscard]] constexpr std::strong_ordering operator<=>(UInt128 const&) const noexcept = default; +}; + +/// A quotient and a remainder. +struct UInt128Division +{ + /// The quotient, rounded toward zero. + UInt128 quotient {}; + /// What is left over: below the divisor. + UInt128 remainder {}; +}; + +/// The portable arithmetic on two words. Every compiler builds it and the +/// tests check it; `Int128` uses it where the compiler has no 128-bit +/// integer of its own. +namespace portable +{ + /// The sum, wrapping past 2^128. + [[nodiscard]] constexpr UInt128 add(UInt128 leftOperand, UInt128 rightOperand) noexcept + { + std::uint64_t const lowSum = leftOperand.lowWord + rightOperand.lowWord; + std::uint64_t const carried = lowSum < leftOperand.lowWord ? 1U : 0U; + return UInt128 { leftOperand.highWord + rightOperand.highWord + carried, lowSum }; + } + + /// The difference, wrapping below zero. + [[nodiscard]] constexpr UInt128 subtract(UInt128 leftOperand, UInt128 rightOperand) noexcept + { + std::uint64_t const borrowed = leftOperand.lowWord < rightOperand.lowWord ? 1U : 0U; + return UInt128 { leftOperand.highWord - rightOperand.highWord - borrowed, leftOperand.lowWord - rightOperand.lowWord }; + } + + /// The full product of two words, from their 32-bit halves. + [[nodiscard]] constexpr UInt128 multiply_words(std::uint64_t leftWord, std::uint64_t rightWord) noexcept + { + constexpr std::uint64_t HalfMask = 0xFFFF'FFFFU; + std::uint64_t const lowLow = (leftWord & HalfMask) * (rightWord & HalfMask); + std::uint64_t const highLow = (leftWord >> 32) * (rightWord & HalfMask); + std::uint64_t const lowHigh = (leftWord & HalfMask) * (rightWord >> 32); + std::uint64_t const highHigh = (leftWord >> 32) * (rightWord >> 32); + std::uint64_t const middle = (lowLow >> 32) + (highLow & HalfMask) + (lowHigh & HalfMask); + return UInt128 { highHigh + (highLow >> 32) + (lowHigh >> 32) + (middle >> 32), + (middle << 32) | (lowLow & HalfMask) }; + } + + /// The product's low 128 bits. + [[nodiscard]] constexpr UInt128 multiply(UInt128 leftOperand, UInt128 rightOperand) noexcept + { + UInt128 product = multiply_words(leftOperand.lowWord, rightOperand.lowWord); + // Each cross term lands in the high word; what passes 2^128 wraps away. + product.highWord += leftOperand.highWord * rightOperand.lowWord + leftOperand.lowWord * rightOperand.highWord; + return product; + } + + /// The product, or nothing when it needs more than 128 bits. + [[nodiscard]] constexpr std::optional multiply_checked(UInt128 leftOperand, UInt128 rightOperand) noexcept + { + if (leftOperand.highWord != 0 && rightOperand.highWord != 0) + return std::nullopt; + UInt128 product = multiply_words(leftOperand.lowWord, rightOperand.lowWord); + // At most one of the two cross terms is not zero. + UInt128 const crossTerm = leftOperand.highWord != 0 ? multiply_words(leftOperand.highWord, rightOperand.lowWord) + : multiply_words(leftOperand.lowWord, rightOperand.highWord); + if (crossTerm.highWord != 0) + return std::nullopt; + std::uint64_t const raisedHigh = product.highWord + crossTerm.lowWord; + if (raisedHigh < product.highWord) + return std::nullopt; + product.highWord = raisedHigh; + return product; + } + + /// Shifted left by @p places, below 128; the bits shifted out are lost. + [[nodiscard]] constexpr UInt128 shift_left(UInt128 operandValue, int places) noexcept + { + if (places == 0) + return operandValue; + if (places >= 64) + return UInt128 { operandValue.lowWord << (places - 64), 0 }; + return UInt128 { (operandValue.highWord << places) | (operandValue.lowWord >> (64 - places)), + operandValue.lowWord << places }; + } + + /// Shifted right by @p places, below 128, with zeros shifted in. + [[nodiscard]] constexpr UInt128 shift_right(UInt128 operandValue, int places) noexcept + { + if (places == 0) + return operandValue; + if (places >= 64) + return UInt128 { 0, operandValue.highWord >> (places - 64) }; + return UInt128 { operandValue.highWord >> places, + (operandValue.lowWord >> places) | (operandValue.highWord << (64 - places)) }; + } + + /// Long division, one bit of the quotient a step. @pre @p divisor is not zero. + [[nodiscard]] constexpr UInt128Division divide(UInt128 dividend, UInt128 divisor) noexcept + { + if (dividend < divisor) + return UInt128Division { UInt128 {}, dividend }; + int const shiftedBy = dividend.bit_width() - divisor.bit_width(); + UInt128 shiftedDivisor = shift_left(divisor, shiftedBy); + UInt128 quotientSoFar {}; + UInt128 remaining = dividend; + for (int place = shiftedBy; place >= 0; --place) + { + quotientSoFar = shift_left(quotientSoFar, 1); + if (!(remaining < shiftedDivisor)) + { + remaining = subtract(remaining, shiftedDivisor); + quotientSoFar.lowWord |= 1U; + } + shiftedDivisor = shift_right(shiftedDivisor, 1); + } + return UInt128Division { quotientSoFar, remaining }; + } +} // namespace portable + +#if FORMULA_NATIVE_INT128 +/// The compiler's own unsigned 128-bit integer. `__extension__` keeps +/// `-Wpedantic` quiet under `-std=c++23`; nothing outside this header and its +/// tests names it, and it is never handed to the standard library, whose +/// type traits do not count it as an integer in that mode. +__extension__ typedef unsigned __int128 NativeUInt128; + +/// @p operandValue as the compiler's own integer. +[[nodiscard]] constexpr NativeUInt128 to_native(UInt128 operandValue) noexcept +{ + return (static_cast(operandValue.highWord) << 64) | operandValue.lowWord; +} + +/// The compiler's own integer @p operandValue as two words. +[[nodiscard]] constexpr UInt128 from_native(NativeUInt128 operandValue) noexcept +{ + return UInt128 { static_cast(operandValue >> 64), static_cast(operandValue) }; +} +#endif + +/// The sum, wrapping past 2^128. +[[nodiscard]] constexpr UInt128 u128_add(UInt128 leftOperand, UInt128 rightOperand) noexcept +{ + return portable::add(leftOperand, rightOperand); +} + +/// The difference, wrapping below zero. +[[nodiscard]] constexpr UInt128 u128_sub(UInt128 leftOperand, UInt128 rightOperand) noexcept +{ + return portable::subtract(leftOperand, rightOperand); +} + +/// The full product of two words. +[[nodiscard]] constexpr UInt128 u128_mul_words(std::uint64_t leftWord, std::uint64_t rightWord) noexcept +{ +#if FORMULA_NATIVE_INT128 + return from_native(static_cast(leftWord) * rightWord); +#else + return portable::multiply_words(leftWord, rightWord); +#endif +} + +/// The product's low 128 bits. +[[nodiscard]] constexpr UInt128 u128_mul(UInt128 leftOperand, UInt128 rightOperand) noexcept +{ +#if FORMULA_NATIVE_INT128 + return from_native(to_native(leftOperand) * to_native(rightOperand)); +#else + return portable::multiply(leftOperand, rightOperand); +#endif +} + +/// The product, or nothing when it needs more than 128 bits. +[[nodiscard]] constexpr std::optional u128_mul_checked(UInt128 leftOperand, UInt128 rightOperand) noexcept +{ +#if FORMULA_NATIVE_INT128 + NativeUInt128 product = 0; + if (__builtin_mul_overflow(to_native(leftOperand), to_native(rightOperand), &product)) + return std::nullopt; + return from_native(product); +#else + return portable::multiply_checked(leftOperand, rightOperand); +#endif +} + +/// Quotient and remainder. @pre @p divisor is not zero. +[[nodiscard]] constexpr UInt128Division u128_divmod(UInt128 dividend, UInt128 divisor) noexcept +{ + if (dividend.fits_u64() && divisor.fits_u64()) + return UInt128Division { UInt128::from_u64(dividend.lowWord / divisor.lowWord), + UInt128::from_u64(dividend.lowWord % divisor.lowWord) }; +#if FORMULA_NATIVE_INT128 + NativeUInt128 const nativeDividend = to_native(dividend); + NativeUInt128 const nativeDivisor = to_native(divisor); + return UInt128Division { from_native(nativeDividend / nativeDivisor), from_native(nativeDividend % nativeDivisor) }; +#else + return portable::divide(dividend, divisor); +#endif +} + +/// How many zero bits end @p operandValue. @pre it is not zero. +[[nodiscard]] constexpr int u128_countr_zero(UInt128 operandValue) noexcept +{ + return operandValue.lowWord != 0 ? std::countr_zero(operandValue.lowWord) : 64 + std::countr_zero(operandValue.highWord); +} + +/// The greatest common divisor; that of 0 and n is n. Binary: no step +/// divides, where each of Euclid's would be a 128-bit division. Both +/// operands of 64 bits, at the start or on the way, finish in 64 bits. +[[nodiscard]] constexpr UInt128 u128_gcd(UInt128 leftOperand, UInt128 rightOperand) noexcept +{ + if (leftOperand.fits_u64() && rightOperand.fits_u64()) + return UInt128::from_u64(std::gcd(leftOperand.lowWord, rightOperand.lowWord)); + if (leftOperand.is_zero()) + return rightOperand; + if (rightOperand.is_zero()) + return leftOperand; + int const sharedTwos = u128_countr_zero( + UInt128 { leftOperand.highWord | rightOperand.highWord, leftOperand.lowWord | rightOperand.lowWord }); + leftOperand = portable::shift_right(leftOperand, u128_countr_zero(leftOperand)); + for (;;) + { + rightOperand = portable::shift_right(rightOperand, u128_countr_zero(rightOperand)); + if (rightOperand < leftOperand) + std::swap(leftOperand, rightOperand); + rightOperand = portable::subtract(rightOperand, leftOperand); + if (rightOperand.is_zero()) + break; + if (leftOperand.fits_u64() && rightOperand.fits_u64()) + { + leftOperand = UInt128::from_u64(std::gcd(leftOperand.lowWord, rightOperand.lowWord)); + break; + } + } + return portable::shift_left(leftOperand, sharedTwos); +} + +/// The largest r with r * r <= @p radicand, which is below 2^64 for every +/// radicand: one bit of the root a step, from the top. +[[nodiscard]] constexpr std::uint64_t u128_isqrt(UInt128 radicand) noexcept +{ + std::uint64_t rootSoFar = 0; + for (int bitAt = 63; bitAt >= 0; --bitAt) + { + std::uint64_t const candidate = rootSoFar | (std::uint64_t { 1 } << bitAt); + if (!(radicand < u128_mul_words(candidate, candidate))) + rootSoFar = candidate; + } + return rootSoFar; +} + +/// 10^@p exponent for 0 to 38, and nothing otherwise: 10^38 is the largest +/// power of ten below 2^127. +[[nodiscard]] constexpr std::optional u128_pow10(int exponent) noexcept +{ + if (exponent < 0 || exponent > 38) + return std::nullopt; + UInt128 power = UInt128::from_u64(1); + for (int multiplied = 0; multiplied < exponent; ++multiplied) + power = u128_mul(power, UInt128::from_u64(10)); + return power; +} + +/// The decimal digits of a 128-bit magnitude: at most 39. +struct DecimalSpelling +{ + /// The digits, most significant first; the first `length` are written. + char characters[39] {}; + /// How many digits there are: 1 for zero. + int length = 0; +}; + +/// @p magnitudeShown's decimal digits. +[[nodiscard]] constexpr DecimalSpelling u128_decimal(UInt128 magnitudeShown) noexcept +{ + char reversed[39] {}; + int produced = 0; + do + { + UInt128Division const split = u128_divmod(magnitudeShown, UInt128::from_u64(10)); + reversed[produced] = static_cast('0' + split.remainder.lowWord); + ++produced; + magnitudeShown = split.quotient; + } while (!magnitudeShown.is_zero()); + DecimalSpelling written {}; + written.length = produced; + for (int at = 0; at < produced; ++at) + written.characters[at] = reversed[produced - 1 - at]; + return written; +} + +} // namespace formula::detail + +namespace formula +{ + +/// A signed 128-bit integer: `Rational`'s numerator and denominator. See +/// this header's file comment for how it computes, and why it converts to no +/// built-in integer type. +class Int128 +{ + public: + /// Zero. + constexpr Int128() noexcept = default; + + /// @p whole, exactly: every built-in integer of up to 64 bits fits. Not + /// `bool`, which is no number. + template + requires std::is_integral_v && (!std::is_same_v, bool>) && (sizeof(T) <= 8) + constexpr Int128(T whole) noexcept: + _highWord { sign_word(whole) }, + _lowWord { static_cast(whole) } + { + } + + /// The value whose two's complement words are @p highWord and @p lowWord. + [[nodiscard]] static constexpr Int128 from_words(std::uint64_t highWord, std::uint64_t lowWord) noexcept + { + Int128 made {}; + made._highWord = highWord; + made._lowWord = lowWord; + return made; + } + + /// Bits 64 to 127 of the two's complement form. + [[nodiscard]] constexpr std::uint64_t high_word() const noexcept { return _highWord; } + /// Bits 0 to 63 of the two's complement form. + [[nodiscard]] constexpr std::uint64_t low_word() const noexcept { return _lowWord; } + + /// Whether it is below zero. + [[nodiscard]] constexpr bool is_negative() const noexcept { return (_highWord >> 63) != 0U; } + + /// Whether `std::int64_t` holds it. + [[nodiscard]] constexpr bool fits_int64() const noexcept + { + return _highWord == ((_lowWord >> 63) != 0U ? ~std::uint64_t { 0 } : std::uint64_t { 0 }); + } + + /// It as a `std::int64_t`, or nothing when it does not fit. + [[nodiscard]] constexpr std::optional to_int64() const noexcept + { + if (!fits_int64()) + return std::nullopt; + // Well defined since C++20: conversion to a signed type is modular. + return static_cast(_lowWord); + } + + /// It as a `std::uint64_t`, or nothing when it is negative or does not fit. + [[nodiscard]] constexpr std::optional to_uint64() const noexcept + { + if (_highWord != 0U) + return std::nullopt; + return _lowWord; + } + + /// The nearest `double`, ties to even. Named, as `Rational::to_double` is, + /// so that every loss of exactness is visible where it happens. + [[nodiscard]] constexpr double to_double() const noexcept + { + if (fits_int64()) + return static_cast(static_cast(_lowWord)); + detail::UInt128 const magnitudeOf = magnitude_pattern(); + int const dropped = magnitudeOf.bit_width() - 64; + detail::UInt128 const kept = detail::portable::shift_right(magnitudeOf, dropped); + // A sticky bit below the 53 a double keeps: rounding to nearest then + // ties only when the bits dropped are exactly half a unit. + bool const inexact = !(detail::portable::shift_left(kept, dropped) == magnitudeOf); + double widened = static_cast(kept.lowWord | (inexact ? 1U : 0U)); + for (int doubled = 0; doubled < dropped; ++doubled) + widened *= 2.0; + return is_negative() ? -widened : widened; + } + + /// Numeric equality. + [[nodiscard]] constexpr bool operator==(Int128 const&) const noexcept = default; + + /// Numeric order. + [[nodiscard]] constexpr std::strong_ordering operator<=>(Int128 const& compared) const noexcept + { + if (_highWord != compared._highWord) + return static_cast(_highWord) <=> static_cast(compared._highWord); + return _lowWord <=> compared._lowWord; + } + + /// The sum, wrapping past the range. + [[nodiscard]] friend constexpr Int128 operator+(Int128 leftOperand, Int128 rightOperand) noexcept + { + return from_pattern(detail::portable::add(leftOperand.as_pattern(), rightOperand.as_pattern())); + } + + /// The difference, wrapping past the range. + [[nodiscard]] friend constexpr Int128 operator-(Int128 leftOperand, Int128 rightOperand) noexcept + { + return from_pattern(detail::portable::subtract(leftOperand.as_pattern(), rightOperand.as_pattern())); + } + + /// The product, wrapping past the range. + [[nodiscard]] friend constexpr Int128 operator*(Int128 leftOperand, Int128 rightOperand) noexcept + { + return from_pattern(detail::u128_mul(leftOperand.as_pattern(), rightOperand.as_pattern())); + } + + /// The quotient, rounded toward zero. @pre @p divisor is not zero. + [[nodiscard]] friend constexpr Int128 operator/(Int128 dividend, Int128 divisor) noexcept + { + Int128 const quotientMagnitude = + from_pattern(detail::u128_divmod(dividend.magnitude_pattern(), divisor.magnitude_pattern()).quotient); + return dividend.is_negative() != divisor.is_negative() ? -quotientMagnitude : quotientMagnitude; + } + + /// The remainder, of the dividend's sign. @pre @p divisor is not zero. + [[nodiscard]] friend constexpr Int128 operator%(Int128 dividend, Int128 divisor) noexcept + { + Int128 const remainderMagnitude = + from_pattern(detail::u128_divmod(dividend.magnitude_pattern(), divisor.magnitude_pattern()).remainder); + return dividend.is_negative() ? -remainderMagnitude : remainderMagnitude; + } + + /// Shifted left by @p places, below 128. + [[nodiscard]] friend constexpr Int128 operator<<(Int128 operandValue, int places) noexcept + { + return from_pattern(detail::portable::shift_left(operandValue.as_pattern(), places)); + } + + /// Shifted right by @p places, below 128, copying the sign into the bits + /// vacated: -8 >> 1 is -4. + [[nodiscard]] friend constexpr Int128 operator>>(Int128 operandValue, int places) noexcept + { + detail::UInt128 const shifted = detail::portable::shift_right(operandValue.as_pattern(), places); + if (!operandValue.is_negative() || places == 0) + return from_pattern(shifted); + detail::UInt128 const signFill = + detail::portable::shift_left(detail::UInt128 { ~std::uint64_t { 0 }, ~std::uint64_t { 0 } }, 128 - places); + return from_pattern(detail::UInt128 { shifted.highWord | signFill.highWord, shifted.lowWord | signFill.lowWord }); + } + + /// The negation, wrapping: the minimum's is itself. + [[nodiscard]] friend constexpr Int128 operator-(Int128 operandValue) noexcept + { + return from_pattern(detail::portable::subtract(detail::UInt128 {}, operandValue.as_pattern())); + } + + /// Itself. + [[nodiscard]] friend constexpr Int128 operator+(Int128 operandValue) noexcept { return operandValue; } + + /// `*this = *this + rightOperand`. + constexpr Int128& operator+=(Int128 rightOperand) noexcept { return *this = *this + rightOperand; } + /// `*this = *this - rightOperand`. + constexpr Int128& operator-=(Int128 rightOperand) noexcept { return *this = *this - rightOperand; } + /// `*this = *this * rightOperand`. + constexpr Int128& operator*=(Int128 rightOperand) noexcept { return *this = *this * rightOperand; } + /// `*this = *this / rightOperand`. + constexpr Int128& operator/=(Int128 rightOperand) noexcept { return *this = *this / rightOperand; } + /// `*this = *this % rightOperand`. + constexpr Int128& operator%=(Int128 rightOperand) noexcept { return *this = *this % rightOperand; } + /// `*this = *this << places`. + constexpr Int128& operator<<=(int places) noexcept { return *this = *this << places; } + /// `*this = *this >> places`. + constexpr Int128& operator>>=(int places) noexcept { return *this = *this >> places; } + /// Adds one. + constexpr Int128& operator++() noexcept { return *this += 1; } + /// Subtracts one. + constexpr Int128& operator--() noexcept { return *this -= 1; } + /// Adds one, answering the value before. + constexpr Int128 operator++(int) noexcept + { + Int128 const before = *this; + ++*this; + return before; + } + /// Subtracts one, answering the value before. + constexpr Int128 operator--(int) noexcept + { + Int128 const before = *this; + --*this; + return before; + } + + private: + template + [[nodiscard]] static constexpr std::uint64_t sign_word(T whole) noexcept + { + if constexpr (std::is_signed_v) + return whole < 0 ? ~std::uint64_t { 0 } : std::uint64_t { 0 }; + else + return 0; + } + + [[nodiscard]] constexpr detail::UInt128 as_pattern() const noexcept { return detail::UInt128 { _highWord, _lowWord }; } + + [[nodiscard]] static constexpr Int128 from_pattern(detail::UInt128 bitPattern) noexcept + { + return from_words(bitPattern.highWord, bitPattern.lowWord); + } + + [[nodiscard]] constexpr detail::UInt128 magnitude_pattern() const noexcept + { + return is_negative() ? detail::portable::subtract(detail::UInt128 {}, as_pattern()) : as_pattern(); + } + + std::uint64_t _highWord = 0; + std::uint64_t _lowWord = 0; +}; + +namespace detail +{ + /// The magnitude of @p operandValue: 2^127 for the minimum, which has no + /// signed counterpart. + [[nodiscard]] constexpr UInt128 magnitude(Int128 operandValue) noexcept + { + UInt128 const wordPattern { operandValue.high_word(), operandValue.low_word() }; + return operandValue.is_negative() ? portable::subtract(UInt128 {}, wordPattern) : wordPattern; + } + + /// The `Int128` of magnitude @p magnitudeOf, negative when @p negative. + /// @pre it fits: @p magnitudeOf is below 2^127, or is 2^127 and @p negative. + [[nodiscard]] constexpr Int128 signed_from_magnitude(UInt128 magnitudeOf, bool negative) noexcept + { + Int128 const positive = Int128::from_words(magnitudeOf.highWord, magnitudeOf.lowWord); + return negative ? -positive : positive; + } +} // namespace detail + +} // namespace formula + +namespace std +{ +/// `formula::Int128`'s limits, stated as a built-in signed integer's are. +template <> +class numeric_limits +{ + public: + static constexpr bool is_specialized = true; + static constexpr bool is_signed = true; + static constexpr bool is_integer = true; + static constexpr bool is_exact = true; + static constexpr bool has_infinity = false; + static constexpr bool has_quiet_NaN = false; + static constexpr bool has_signaling_NaN = false; + static constexpr std::float_round_style round_style = std::round_toward_zero; + static constexpr bool is_iec559 = false; + static constexpr bool is_bounded = true; + static constexpr bool is_modulo = false; + static constexpr int digits = 127; + static constexpr int digits10 = 38; + static constexpr int max_digits10 = 0; + static constexpr int radix = 2; + static constexpr int min_exponent = 0; + static constexpr int min_exponent10 = 0; + static constexpr int max_exponent = 0; + static constexpr int max_exponent10 = 0; + static constexpr bool traps = false; + static constexpr bool tinyness_before = false; + + /// -2^127. + [[nodiscard]] static constexpr formula::Int128 min() noexcept + { + return formula::Int128::from_words(std::uint64_t { 1 } << 63, 0); + } + /// -2^127. + [[nodiscard]] static constexpr formula::Int128 lowest() noexcept { return min(); } + /// 2^127 - 1. + [[nodiscard]] static constexpr formula::Int128 max() noexcept + { + return formula::Int128::from_words(~(std::uint64_t { 1 } << 63), ~std::uint64_t { 0 }); + } + /// Zero: an integer has none. + [[nodiscard]] static constexpr formula::Int128 epsilon() noexcept { return 0; } + /// Zero: an integer has none. + [[nodiscard]] static constexpr formula::Int128 round_error() noexcept { return 0; } + /// Zero: an integer has none. + [[nodiscard]] static constexpr formula::Int128 infinity() noexcept { return 0; } + /// Zero: an integer has none. + [[nodiscard]] static constexpr formula::Int128 quiet_NaN() noexcept { return 0; } + /// Zero: an integer has none. + [[nodiscard]] static constexpr formula::Int128 signaling_NaN() noexcept { return 0; } + /// Zero: an integer has none. + [[nodiscard]] static constexpr formula::Int128 denorm_min() noexcept { return 0; } +}; +} // namespace std +``` + + If a hygiene check refuses the `#define` or the `std::` specialisation, follow the check's message. `FORMULA_NO_UNIQUE_ADDRESS` is precedent for an internal macro. + +- [ ] **Step 4: The formatter.** In `include/formula-cpp/format.hpp`, add `#include ` and `#include `. After `formatter` (`:603`) add: + +```cpp +/// `std::format` of a `formula::Int128`: its decimal digits, with a `-` when +/// it is negative, as `{}` writes a built-in integer. The empty spec is the +/// only one; any other calls `formula_number_format_spec_not_understood`, a +/// compile error in a literal format string. +/// +/// Owned by this library: a consumer's own specialisation of it would define +/// it twice, which breaks the one-definition rule. +template <> +struct formatter +{ + /// Accepts only the empty spec. + constexpr auto parse(std::format_parse_context& parseContext) + { + auto const specAt = parseContext.begin(); + if (specAt != parseContext.end() && *specAt != '}') + formula::detail::formula_number_format_spec_not_understood(); + return specAt; + } + + /// Writes @p shown's digits. + template + auto format(formula::Int128 const& shown, FormatContext& formatContext) const + { + formula::detail::DecimalSpelling const spelled = formula::detail::u128_decimal(formula::detail::magnitude(shown)); + auto writtenTo = formatContext.out(); + if (shown.is_negative()) + *writtenTo++ = '-'; + return std::copy_n(spelled.characters, spelled.length, writtenTo); + } +}; +``` + + Also add `formula::Int128` to the file comment's list of owned specialisations (`format.hpp:44-52`). + +- [ ] **Step 5: Install it, and probe it.** + - Add `include/formula-cpp/int128.hpp` to the `FILE_SET` in `CMakeLists.txt`. + - In `test/consumer_globals_tests.cpp`, add `#include ` in alphabetical order. Add a use that instantiates the constructor template, every operator and `std::format`, following the file's pattern. Check its result in `consumer_globals_run_tests.cpp`. + - Add `int128_tests.cpp` to `add_executable(formula-cpp-tests` in `test/CMakeLists.txt`, after `checked_int_tests.cpp`. + +- [ ] **Step 6: Run the tests.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "int128"`. Expected: 8 pass. The native agreement test is not compiled on cl. + Then `pwsh -NoProfile -File $S\neg.ps1 -Tree $T -Filter "int128_format_spec_not_understood"`, following the negative-test protocol in *Execution*: wrong text first, then a deletion check of the guard call in `parse`, with the guard restored by a plain write. + +- [ ] **Step 7: Verify, including the native path.** + 1. Verify step 1 must print `ALL OK`. Delta: **+8 tests**. + 2. Verify step 2 with `-Filter "int128_format_spec_not_understood|format_spec_not_understood"`. Delta: **+1 negative**. + 3. Verify step 3, on `gcc-release` and `clang-debug` under WSL, must print `MATRIX OK`. There the native agreement test runs, making 9 `[int128]` tests. Report its count. + +- [ ] **Step 8: Commit.** + Message: `feat: add formula::Int128, a 128-bit integer that is native where the compiler has one and constexpr everywhere`. + Then the `Signed-off-by` line. + +### Task 5: Readers of `Rational` made width-agnostic, with no change in behaviour + +`Rational::Int` is still `std::int64_t` throughout this task. Every function that reads a numerator or a denominator, or builds a `Rational::Int` from a magnitude, moves onto helpers that work at 64 bits now and at 128 bits after Task 6. **Every test result stays exactly as it was**, the overflow census included. The new helpers get their own tests. + +**Files:** +- Modify: `include/formula-cpp/detail/checked_int.hpp`: + - include `int128.hpp`; + - the census hook takes a `UInt128`; + - add `wide_magnitude`, `narrow_to_int64`, `int_from_pattern`; + - add `Int128` overloads of `add/sub/mul_checked_or_none`, `floor_divmod`, `decimal_digits` and `mul_pow10`, and `gcd(UInt128, UInt128)`. +- Modify: `support/census_tally.cpp`, `support/census_tally.hpp`: keep `UInt128` magnitudes. Headroom stays out of 63 here. +- Modify: `include/formula-cpp/detail/wide_int.hpp`: `WideUnsigned::from_u128`, `to_u128`. +- Modify: `include/formula-cpp/rational.hpp`: add `detail::rational_int_from_magnitude` after the class. +- Modify: `include/formula-cpp/number_text.hpp`: + - `NumberTextCapacity` and `LongestNumberText`; + - `put_whole(UInt128)`; + - `exact_decimal_digits`, `has_exact_decimal`, `fraction_text`, `checked_decimal_text` and `moves_away_from_zero` move to 128-bit magnitudes. +- Modify: `include/formula-cpp/rounding.hpp`: `at_least_pow10`, `checked_decimal_exponent`. +- Modify: `include/formula-cpp/detail/wide_rounding.hpp`: `wide_from_rational`, `scaled_to_denominator`, `round_wide_ratio`, `narrow_wide_ratio`. +- Modify: `include/formula-cpp/detail/transcendental.hpp`: `natural_log_magnitude` (`:218-248`) and `exponential_enclosure` (`:288-293`) refuse a fraction wider than 64 bits. +- Modify: `include/formula-cpp/critical_value.hpp`: `find_sample_size` (`:283-297`) and `as_sample_size` (`:299-308`), with their callers `critical_value.hpp:456` and `trace.hpp:2847-2856`. +- Modify: `include/formula-cpp/detail/least_squares_kernel.hpp:136-141`. +- Modify: `include/formula-cpp/band.hpp:116-119`, `include/formula-cpp/lookup.hpp:1330-1335`, `include/formula-cpp/trace.hpp:2542-2549` (`point_in`), `include/formula-cpp/trace.hpp:2720-2731` (`unit_quotient`), `include/formula-cpp/rounded_root.hpp:266-279` (`rounded_square_root_in`'s units). +- Test: `test/checked_int_tests.cpp`, `test/wide_rounding_tests.cpp`, `test/rational_tests.cpp`. + +**Interfaces:** +- Consumes, from Task 4: `UInt128`, `UInt128Division`, `u128_*`, `magnitude(Int128)`, `signed_from_magnitude`, `Int128`. +- Produces, in `formula::detail`: + - `wide_magnitude(Int) -> UInt128` and `wide_magnitude(Int128) -> UInt128`; + - `narrow_to_int64(Int)` and `narrow_to_int64(Int128) -> std::optional`; + - `int_from_pattern(UInt128, std::type_identity) -> Int` and `int_from_pattern(UInt128, std::type_identity) -> Int128`; + - `add_checked_or_none`, `sub_checked_or_none`, `mul_checked_or_none` on `Int128`, each `-> std::optional`; + - `struct WideDivMod { Int128 quotient; Int128 remainder; }` and `floor_divmod(Int128, Int128) -> WideDivMod`; + - `decimal_digits(Int128) -> int`, `mul_pow10(Int128, int) -> std::optional` (exponents 0–18 as on 64 bits), and `gcd(UInt128, UInt128) -> UInt128`; + - `census_record(CensusRole, UInt128)`, with `census_note` overloads for `UInt128` and `std::uint64_t`; + - `rational_int_from_magnitude(UInt128, bool) -> std::optional`; + - `WideUnsigned::from_u128(UInt128)` (`L >= 4`) and `WideUnsigned::to_u128() -> std::optional`; + - `as_sample_size(Rational) -> std::optional` and `find_sample_size(UInt128)`; + - `formula_band_bound_out_of_range()` and `formula_breakpoint_key_out_of_range()`, non-`constexpr` `[[noreturn]]` guards. + +- [ ] **Step 1: Write the failing tests.** Add to `test/checked_int_tests.cpp` (add `#include ` and `#include ` if absent): + +```cpp +TEST_CASE("the 128-bit checked operations refuse exactly at the range's ends", "[checked-int]") +{ + using formula::Int128; + using formula::detail::add_checked_or_none; + using formula::detail::mul_checked_or_none; + using formula::detail::sub_checked_or_none; + constexpr Int128 largest = std::numeric_limits::max(); + constexpr Int128 smallest = std::numeric_limits::min(); + STATIC_REQUIRE(add_checked_or_none(largest - 1, Int128 { 1 }) == std::optional { largest }); + STATIC_REQUIRE(add_checked_or_none(largest, Int128 { 1 }) == std::nullopt); + STATIC_REQUIRE(add_checked_or_none(smallest, Int128 { -1 }) == std::nullopt); + STATIC_REQUIRE(sub_checked_or_none(Int128 { -1 }, largest) == std::optional { smallest }); + STATIC_REQUIRE(sub_checked_or_none(smallest, Int128 { 1 }) == std::nullopt); + STATIC_REQUIRE(sub_checked_or_none(largest, Int128 { -1 }) == std::nullopt); + // 2^63 * 2^64 = 2^127: one past the largest, and exactly the smallest when negative. + constexpr Int128 twoTo63 = Int128 { 1 } << 63; + constexpr Int128 twoTo64 = Int128 { 1 } << 64; + STATIC_REQUIRE(mul_checked_or_none(twoTo63, twoTo64) == std::nullopt); + STATIC_REQUIRE(mul_checked_or_none(-twoTo63, twoTo64) == std::optional { smallest }); + STATIC_REQUIRE(mul_checked_or_none(twoTo63, -twoTo64) == std::optional { smallest }); + STATIC_REQUIRE(mul_checked_or_none(smallest, Int128 { -1 }) == std::nullopt); + STATIC_REQUIRE(mul_checked_or_none(smallest, Int128 { 1 }) == std::optional { smallest }); + STATIC_REQUIRE(mul_checked_or_none(Int128 { 0 }, smallest) == std::optional { Int128 { 0 } }); +} + +TEST_CASE("the helpers that read an integer of either width", "[checked-int]") +{ + using formula::Int128; + using formula::detail::UInt128; + STATIC_REQUIRE(formula::detail::wide_magnitude(std::int64_t { -5 }) == UInt128::from_u64(5)); + STATIC_REQUIRE(formula::detail::wide_magnitude(std::numeric_limits::min()) + == UInt128::from_u64(std::uint64_t { 1 } << 63)); + STATIC_REQUIRE(formula::detail::wide_magnitude(std::numeric_limits::min()) == UInt128 { std::uint64_t { 1 } << 63, 0 }); + STATIC_REQUIRE(formula::detail::narrow_to_int64(Int128 { -7 }) == std::optional { -7 }); + STATIC_REQUIRE(formula::detail::narrow_to_int64(Int128 { 1 } << 63) == std::nullopt); + STATIC_REQUIRE(formula::detail::int_from_pattern(UInt128 { ~std::uint64_t { 0 }, ~std::uint64_t { 0 } - 4 }, + std::type_identity {}) + == Int128 { -5 }); + STATIC_REQUIRE(formula::detail::int_from_pattern(UInt128 { ~std::uint64_t { 0 }, ~std::uint64_t { 0 } - 4 }, + std::type_identity {}) + == std::int64_t { -5 }); + STATIC_REQUIRE(formula::detail::floor_divmod(Int128 { -7 }, Int128 { 2 }).quotient == Int128 { -4 }); + STATIC_REQUIRE(formula::detail::floor_divmod(Int128 { -7 }, Int128 { 2 }).remainder == Int128 { 1 }); + STATIC_REQUIRE(formula::detail::decimal_digits(std::numeric_limits::max()) == 39); + 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); +} +``` + + Add to `test/wide_rounding_tests.cpp`: + +```cpp +TEST_CASE("a wide integer holds 128 bits exactly, and says when it holds more", "[wide-int]") +{ + using formula::detail::UInt128; + using formula::detail::WideUnsigned; + constexpr UInt128 widest { ~std::uint64_t { 0 }, ~std::uint64_t { 0 } }; + STATIC_REQUIRE(WideUnsigned<4>::from_u128(widest).to_u128() == std::optional { widest }); + STATIC_REQUIRE(WideUnsigned<8>::from_u128(UInt128 { 0x0123456789abcdef, 0xfedcba9876543210 }).to_u128() + == std::optional { UInt128 { 0x0123456789abcdef, 0xfedcba9876543210 } }); + constexpr auto twoTo128 = formula::detail::shift_left_checked_or_none(WideUnsigned<8>::from_u64(1), 128); + STATIC_REQUIRE(twoTo128.has_value()); + STATIC_REQUIRE(twoTo128->to_u128() == std::nullopt); +} +``` + + `shift_left_checked_or_none` is `wide_int.hpp`'s checked left shift (`:251`). Pass the shift count in the type its signature declares. + + Add to `test/rational_tests.cpp`: + +```cpp +TEST_CASE("a Rational::Int is built from a magnitude, or refused when it does not fit", "[rational]") +{ + using formula::detail::UInt128; + using formula::detail::rational_int_from_magnitude; + constexpr auto largestMagnitude = formula::detail::wide_magnitude(std::numeric_limits::max()); + STATIC_REQUIRE(rational_int_from_magnitude(UInt128::from_u64(5), true) == std::optional { -5 }); + STATIC_REQUIRE(rational_int_from_magnitude(largestMagnitude, false) + == std::optional { std::numeric_limits::max() }); + STATIC_REQUIRE(rational_int_from_magnitude(formula::detail::u128_add(largestMagnitude, UInt128::from_u64(1)), true) + == std::optional { std::numeric_limits::min() }); + STATIC_REQUIRE(rational_int_from_magnitude(formula::detail::u128_add(largestMagnitude, UInt128::from_u64(1)), false) + == std::nullopt); +} +``` + +- [ ] **Step 2: Run them to see them fail.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "checked-int|wide-int|rational"`. Expected: a build failure naming the missing helpers. + +- [ ] **Step 3: `checked_int.hpp`.** + - Add `#include ` and `#include `. + - Replace the census declarations (`:66-78`): `census_record` takes `UInt128`; `census_note` has a `UInt128` overload and a `std::uint64_t` one that widens: + +```cpp +/// Told the magnitude of an integer formed at run time, as 128 bits. Declared +/// here and defined only by the census program, never by the library. +void census_record(CensusRole role, UInt128 magnitudeSeen) noexcept; + +/// Tells the overflow census of @p magnitudeSeen, unless this is a constant +/// evaluation. +constexpr void census_note(CensusRole role, UInt128 magnitudeSeen) noexcept +{ + if !consteval + { + census_record(role, magnitudeSeen); + } +} + +/// Tells the overflow census of a 64-bit @p magnitudeSeen. +constexpr void census_note(CensusRole role, std::uint64_t magnitudeSeen) noexcept +{ + census_note(role, UInt128::from_u64(magnitudeSeen)); +} +``` + + In the file comment, change "how many of the 63 bits real formulas use" to "how many of the bits `Rational::Int` holds real formulas use". + + At the end of the namespace, add: + +```cpp +// ---- 128 bits: `Int128`'s checked operations, and helpers that read +// `Rational::Int` whatever its width ------------------------------------------- + +/// @p operandValue's magnitude as 128 bits. +[[nodiscard]] constexpr UInt128 wide_magnitude(Int operandValue) noexcept +{ + return UInt128::from_u64(magnitude(operandValue)); +} + +/// @p operandValue's magnitude: 2^127 for the minimum. +[[nodiscard]] constexpr UInt128 wide_magnitude(Int128 operandValue) noexcept +{ + return magnitude(operandValue); +} + +/// @p operandValue as a 64-bit integer, which it always is. +[[nodiscard]] constexpr std::optional narrow_to_int64(Int operandValue) noexcept +{ + return operandValue; +} + +/// @p operandValue as a 64-bit integer, or nothing when it does not fit. +[[nodiscard]] constexpr std::optional narrow_to_int64(Int128 operandValue) noexcept +{ + return operandValue.to_int64(); +} + +/// The 64-bit integer whose two's complement bits are @p wordPattern's low +/// word. @pre the value fits: the high word is the low word's sign. +[[nodiscard]] constexpr Int int_from_pattern(UInt128 wordPattern, std::type_identity) noexcept +{ + // Well defined since C++20: conversion to a signed type is modular. + return static_cast(wordPattern.lowWord); +} + +/// The `Int128` whose two's complement bits are @p wordPattern. +[[nodiscard]] constexpr Int128 int_from_pattern(UInt128 wordPattern, std::type_identity) noexcept +{ + return Int128::from_words(wordPattern.highWord, wordPattern.lowWord); +} + +[[nodiscard]] constexpr std::optional add_checked_or_none(Int128 leftOperand, Int128 rightOperand) noexcept +{ + Int128 const added = leftOperand + rightOperand; + if (leftOperand.is_negative() == rightOperand.is_negative() && added.is_negative() != leftOperand.is_negative()) + return std::nullopt; + FORMULA_CENSUS_NOTE(Intermediate, magnitude(added)); + return added; +} + +[[nodiscard]] constexpr std::optional sub_checked_or_none(Int128 leftOperand, Int128 rightOperand) noexcept +{ + Int128 const subtracted = leftOperand - rightOperand; + if (leftOperand.is_negative() != rightOperand.is_negative() && subtracted.is_negative() != leftOperand.is_negative()) + return std::nullopt; + FORMULA_CENSUS_NOTE(Intermediate, magnitude(subtracted)); + return subtracted; +} + +[[nodiscard]] constexpr std::optional mul_checked_or_none(Int128 leftOperand, Int128 rightOperand) noexcept +{ + std::optional const productMagnitude = u128_mul_checked(magnitude(leftOperand), magnitude(rightOperand)); + if (!productMagnitude) + return std::nullopt; + bool const negative = !productMagnitude->is_zero() && leftOperand.is_negative() != rightOperand.is_negative(); + UInt128 const largestMagnitude = negative ? UInt128 { std::uint64_t { 1 } << 63, 0 } + : UInt128 { ~(std::uint64_t { 1 } << 63), ~std::uint64_t { 0 } }; + if (largestMagnitude < *productMagnitude) + return std::nullopt; + FORMULA_CENSUS_NOTE(Intermediate, *productMagnitude); + return signed_from_magnitude(*productMagnitude, negative); +} + +/// The greatest common divisor of two 128-bit magnitudes (`u128_gcd`). +[[nodiscard]] constexpr UInt128 gcd(UInt128 leftOperand, UInt128 rightOperand) noexcept +{ + return u128_gcd(leftOperand, rightOperand); +} + +/// `floor_divmod`'s answer on 128 bits. +struct WideDivMod +{ + /// The floored quotient. + Int128 quotient {}; + /// The remainder, in `[0, divisor)`. + Int128 remainder {}; +}; + +/// Floored division on 128 bits, as `floor_divmod` on 64. @pre `divisor > 0`. +[[nodiscard]] constexpr WideDivMod floor_divmod(Int128 dividend, Int128 divisor) noexcept +{ + Int128 truncated = dividend / divisor; + Int128 remainderLeft = dividend % divisor; + if (remainderLeft < 0) + { + --truncated; + remainderLeft += divisor; + } + return { truncated, remainderLeft }; +} + +/// Number of decimal digits in `|operandValue|`: at most 39. Zero has one. +[[nodiscard]] constexpr int decimal_digits(Int128 operandValue) noexcept +{ + 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. +[[nodiscard]] constexpr std::optional mul_pow10(Int128 operandValue, int exponent) noexcept +{ + std::optional const powerOfTen = pow10(exponent); + if (!powerOfTen) + return std::nullopt; + return mul_checked_or_none(operandValue, Int128 { *powerOfTen }); +} +``` + +- [ ] **Step 4: The census tally keeps 128 bits.** + - In `support/census_tally.cpp`, replace `std::array largestSeen {};` with `std::array largestSeen {};`. + - Give `census_record` the signature `void census_record(CensusRole role, UInt128 magnitudeSeen) noexcept` and the body `if (largestSeen[slot(role)] < magnitudeSeen) largestSeen[slot(role)] = magnitudeSeen;`. + - `bits_used` returns `largestSeen[slot(role)].bit_width()`. + - Keep `signed_bits_used`'s `std::min(63, ...)` as it is: Task 6 changes the width. + - In `census_tally.hpp`, change the comment "INT64_MAX uses 63; `rounded_sqrt`'s unsigned intermediates may use 64" to "the largest `Rational::Int` uses all but its sign bit; `rounded_sqrt`'s unsigned intermediates may use every bit". + +- [ ] **Step 5: `WideUnsigned` to and from 128 bits.** In `detail/wide_int.hpp`, add `#include `, and after `from_u64` (`:56-62`): + +```cpp + /// @p narrow, exactly. Four limbs hold it. + [[nodiscard]] static constexpr WideUnsigned from_u128(UInt128 narrow) noexcept + requires(Limbs >= 4) + { + std::array held {}; + held[0] = static_cast(narrow.lowWord & 0xFFFF'FFFFU); + held[1] = static_cast(narrow.lowWord >> 32U); + held[2] = static_cast(narrow.highWord & 0xFFFF'FFFFU); + held[3] = static_cast(narrow.highWord >> 32U); + return from_limbs(held); + } +``` + + And after `to_u64`: + +```cpp + /// This value as 128 bits, or nothing when it needs more. + [[nodiscard]] constexpr std::optional to_u128() const noexcept + { + for (std::size_t limbAt = 4; limbAt < Limbs; ++limbAt) + if (_limbs[limbAt] != 0U) + return std::nullopt; + auto const limbOrZero = [this](std::size_t limbIndex) -> std::uint64_t { + return limbIndex < Limbs ? static_cast(_limbs[limbIndex]) : std::uint64_t { 0 }; + }; + return UInt128 { (limbOrZero(3) << 32U) | limbOrZero(2), (limbOrZero(1) << 32U) | limbOrZero(0) }; + } +``` + +- [ ] **Step 6: `rational_int_from_magnitude`.** In `rational.hpp`, directly after `class Rational`'s closing `};`: + +```cpp +namespace detail +{ + /// The `Rational::Int` of magnitude @p magnitudeOf, negative when + /// @p negative, or nothing when `Rational::Int` cannot hold it: one + /// spelling for code that builds a numerator from a wide magnitude, + /// whatever `Rational::Int`'s width. + [[nodiscard]] constexpr std::optional rational_int_from_magnitude(UInt128 magnitudeOf, + bool negative) noexcept + { + UInt128 const largestPositive = wide_magnitude(std::numeric_limits::max()); + UInt128 const largestAllowed = negative ? u128_add(largestPositive, UInt128::from_u64(1)) : largestPositive; + if (largestAllowed < magnitudeOf) + return std::nullopt; + UInt128 const wordPattern = negative ? u128_sub(UInt128 {}, magnitudeOf) : magnitudeOf; + return int_from_pattern(wordPattern, std::type_identity {}); + } +} // namespace detail +``` + +- [ ] **Step 7: `number_text.hpp`.** + - `NumberTextCapacity` (`:43`) becomes `128`. Its comment, if any, names the longest text. + - `LongestNumberText` (`:254-260`) becomes the following, and the `static_assert` stays: + +```cpp + /// The longest text this header spells: the marker, a sign, the 39 digits + /// of 2^127, then a point and 18 places or a slash and a 39-digit + /// denominator, a space, and a unit symbol of `SymbolCapacity` bytes -- + /// `view(Symbol const&)` returns that many from a symbol with no + /// terminator. + inline constexpr std::size_t LongestNumberText = + ApproximationMarker.size() + 1 + 39 + 1 + 39 + 1 + SymbolCapacity; +``` + + - In `NumberTextAccess`, beside `put_whole(NumberText&, std::uint64_t)`, add: + +```cpp + /// Appends @p wholeNumber in decimal: at most 39 digits. + static constexpr void put_whole(NumberText& spelled, UInt128 wholeNumber) noexcept + { + DecimalSpelling const written = u128_decimal(wholeNumber); + for (int at = 0; at < written.length; ++at) + put(spelled, written.characters[at]); + } +``` + + - `exact_decimal_digits` (`:278-302`), with its comments kept: + +```cpp + [[nodiscard]] constexpr std::optional exact_decimal_digits(Rational shownValue, int minimumPlaces) noexcept + { + UInt128 const divisor = wide_magnitude(shownValue.denominator()); + UInt128Division const scaleSplit = u128_divmod(UInt128::from_u64(ExactDecimalScale), divisor); + if (!scaleSplit.remainder.is_zero()) + return std::nullopt; + + UInt128Division const shownSplit = u128_divmod(wide_magnitude(shownValue.numerator()), divisor); + // The remainder is below the divisor, which divides 10^18, so both it + // and the cofactor 10^18 / divisor fit 64 bits, and so does their + // product, which stays below 10^18. + std::uint64_t fractional = shownSplit.remainder.lowWord * scaleSplit.quotient.lowWord; + char fractionDigits[ExactDecimalPlaces] {}; + for (int place = ExactDecimalPlaces - 1; place >= 0; --place) + { + fractionDigits[place] = static_cast('0' + fractional % 10U); + fractional /= 10U; + } + int shownPlaces = ExactDecimalPlaces; + while (shownPlaces > minimumPlaces && fractionDigits[shownPlaces - 1] == '0') + --shownPlaces; + + NumberText spelled = NumberTextAccess::blank(); + if (shownValue.sign() < 0) + NumberTextAccess::put(spelled, '-'); + NumberTextAccess::put_whole(spelled, shownSplit.quotient); + NumberTextAccess::put_fraction(spelled, fractionDigits, shownPlaces); + return spelled; + } +``` + + - `moves_away_from_zero`: its first two parameters become `UInt128 remainderLeft, UInt128 divisor`. Inside, `if (remainderLeft.is_zero())` and `UInt128 const distanceUp = u128_sub(divisor, remainderLeft);`; the `<` and `>` comparisons stay as written. + - `has_exact_decimal`'s body becomes: + `return detail::u128_divmod(detail::UInt128::from_u64(detail::ExactDecimalScale), detail::wide_magnitude(shownValue.denominator())).remainder.is_zero();` + - `fraction_text`: both `put_whole` calls take `detail::wide_magnitude(...)` of the numerator and of the denominator. + - `checked_decimal_text` (`:436-500`), every 64-bit magnitude becomes a `detail::UInt128`: + +```cpp + detail::UInt128 const divisor = detail::wide_magnitude(unrounded.denominator()); + detail::UInt128Division const shownSplit = detail::u128_divmod(detail::wide_magnitude(unrounded.numerator()), divisor); + detail::UInt128 wholePart = shownSplit.quotient; + detail::UInt128 remainderLeft = shownSplit.remainder; +``` + + The digit loop's body becomes the following. Update the comment above it to "after it the sum is below 2 * divisor < 2^128". + +```cpp + detail::UInt128 tenfold {}; + int nextDigit = 0; + for (int added = 0; added < 10; ++added) + { + tenfold = detail::u128_add(tenfold, remainderLeft); + if (!(tenfold < divisor)) + { + tenfold = detail::u128_sub(tenfold, divisor); + ++nextDigit; + } + } + fractionDigits[place] = static_cast('0' + nextDigit); + remainderLeft = tenfold; +``` + + Then: + - `lastKeptOdd`'s whole-part case: `(wholePart.lowWord & 1U) != 0U`; + - `++wholePart;` becomes `wholePart = detail::u128_add(wholePart, detail::UInt128::from_u64(1));`, its comment changed to "below 2^127: a value with a remainder has a denominator of at least 2"; + - `wholePart == 0U` becomes `wholePart.is_zero()`; + - `remainderLeft != 0U` becomes `!remainderLeft.is_zero()`. + + In the doc comment, "in `std::uint64_t`" becomes "in 128-bit unsigned integers". Task 7 rewrites the `IntMax/3` example. + +- [ ] **Step 8: `rounding.hpp`.** `detail::at_least_pow10` (`:210-237`) becomes: + +```cpp + /// Whether `|numerator| / denominator >= 10^exponent`, exactly and without + /// ever constructing 10^exponent as a Rational -- which is impossible at the + /// extremes of the representable range. A scaled side beyond the largest + /// `Rational::Int` already decides the comparison, and is never formed. + [[nodiscard]] constexpr bool at_least_pow10(UInt128 magnitudeNumerator, + UInt128 magnitudeDenominator, + int exponent) noexcept + { + constexpr UInt128 Largest = wide_magnitude(std::numeric_limits::max()); + if (exponent >= 0) + { + std::optional const powerOfTen = u128_pow10(exponent); + std::optional const scaledDenominator = + powerOfTen ? u128_mul_checked(magnitudeDenominator, *powerOfTen) : std::nullopt; + // Beyond the largest numerator, the quotient is below 10^exponent. + if (!scaledDenominator || Largest < *scaledDenominator) + return false; + FORMULA_CENSUS_NOTE(Intermediate, *scaledDenominator); + return !(magnitudeNumerator < *scaledDenominator); + } + std::optional const powerOfTen = u128_pow10(-exponent); + std::optional const scaledNumerator = + powerOfTen ? u128_mul_checked(magnitudeNumerator, *powerOfTen) : std::nullopt; + if (!scaledNumerator || Largest < *scaledNumerator) + return true; + FORMULA_CENSUS_NOTE(Intermediate, *scaledNumerator); + return !(*scaledNumerator < magnitudeDenominator); + } +``` + + This is the old function's decision in every case, and it notes the same intermediates to the census. At 64 bits, an exponent of 19 or more scales any denominator past `Largest`, exactly where the old `> 18` returned. In `checked_decimal_exponent` (`:248-249`), the two magnitudes become `detail::UInt128 const ... = detail::wide_magnitude(examinedValue.numerator())` and `... .denominator()`. + +- [ ] **Step 9: `wide_rounding.hpp`.** + - `wide_from_rational`: both `from_u64(...)` calls become `WideUnsigned::from_u128(wide_magnitude(...))`, of the numerator and of the denominator. The comment's second sentence becomes "Four limbs at least, so that a 128-bit numerator fits." + - `scaled_to_denominator`: gains `requires(L >= 4)`, and both `from_u64(...)` become `from_u128(wide_magnitude(...))`. + - `round_wide_ratio`'s tail (`:146-155`) becomes: + +```cpp + std::optional> const kept = + awayFromZero ? add_small_checked_or_none(split.quotient, 1U) : split.quotient; + std::optional const keptMagnitude = kept ? kept->to_u128() : std::nullopt; + std::optional const mantissa = + keptMagnitude ? rational_int_from_magnitude(*keptMagnitude, inLowestTerms.negative) : std::nullopt; + if (!mantissa) + return std::unexpected { ArithmeticError::Overflow }; + return Rational::from_decimal(*mantissa, -places.value); +``` + + - `narrow_wide_ratio`'s body after `reduced` (`:167-175`) becomes: + +```cpp + std::optional const numeratorMagnitude = inLowestTerms.numerator.to_u128(); + std::optional const denominatorMagnitude = inLowestTerms.denominator.to_u128(); + std::optional const signedNumerator = + numeratorMagnitude ? rational_int_from_magnitude(*numeratorMagnitude, inLowestTerms.negative) : std::nullopt; + std::optional const positiveDenominator = + denominatorMagnitude ? rational_int_from_magnitude(*denominatorMagnitude, false) : std::nullopt; + if (!signedNumerator || !positiveDenominator) + return std::unexpected { ArithmeticError::Overflow }; + return Rational::make(*signedNumerator, *positiveDenominator); +``` + +- [ ] **Step 10: `transcendental.hpp`.** The kernel works on values below 2^63, so a wider fraction is beyond it and is refused, as any other kernel overflow is. + - In `natural_log_magnitude`, replace the two `static_cast` lines (`:220-221`) with: + +```cpp + std::optional const numeratorWord = narrow_to_int64(positive.numerator()); + std::optional const denominatorWord = narrow_to_int64(positive.denominator()); + // The kernel works on a fraction of two values below 2^63; a wider one is + // beyond it. + if (!numeratorWord || !denominatorWord) + return std::nullopt; + auto larger = static_cast(*numeratorWord); + auto smaller = static_cast(*denominatorWord); +``` + + - In `exponential_enclosure`, the same narrowing goes before `scaled_quotient`. Use `static_cast(*denominatorWord)`, and for the numerator `magnitude(*numeratorWord)`, the 64-bit overload. On failure `return std::nullopt;`. + +- [ ] **Step 11: `critical_value.hpp`, and the trace's sample-size step.** + - `find_sample_size` takes `UInt128 sampleSize` and compares `UInt128::from_u64(static_cast(Sizes[rowIndex])) == sampleSize`. + - `as_sample_size` returns `std::optional`, with the body `if (evaluatedCount.denominator() != 1 || evaluatedCount.numerator() < 0) return std::nullopt; return wide_magnitude(evaluatedCount.numerator());`. Add to its comment: "a count beyond 2^64 - 1 is still a count, which no table declares". + - `critical_value.hpp:456` declares `std::optional const sampleSize`. + - In `trace.hpp:2847-2856`: + +```cpp + std::optional const sampleSize = as_sample_size(*operandValue); + if (!sampleSize.has_value()) + { + step.lookupFailure = LookupFailure::NotACount; + return; + } + // A count beyond 2^64 - 1 is one no table declares: it misses, + // with no key a step can hold. + if (sampleSize->fits_u64()) + step.lookupKey = sampleSize->lowWord; +``` + + Keep the two `if`s after it, passing `*sampleSize` to `find_sample_size`. + +- [ ] **Step 12: `least_squares_kernel.hpp:136-141`.** + +```cpp + UInt128 const denominatorValue = wide_magnitude(observed.denominator()); + if (denominatorValue.fits_u64() && denominatorValue.lowWord <= 0xFFFF'FFFFU + && divmod_small(common, static_cast(denominatorValue.lowWord)).remainder == 0) + continue; + std::optional> const grown = + lcm_checked_or_none(common, WideUnsigned::from_u128(denominatorValue)); +``` + + Add `requires(L >= 4)` to `common_denominator` if the compiler asks for it. + +- [ ] **Step 13: The structural types narrow on purpose.** `Band`, `Breakpoint` and `Unit` hold `std::int64_t`, so they stay structural types. + - **`band.hpp:116-119`**, `band(Rational, Rational)`. Add `#include `. Before the function, add: + +```cpp +namespace detail +{ + /// A `band` bound that a `Band`'s `std::int64_t` numerator or denominator + /// cannot hold. Deliberately not `constexpr`: reaching it in a constant + /// expression fails to compile, naming it. At run time it ends the + /// program -- a `Band` is a template argument, built at compile time, + /// and has no way to carry a failure. + [[noreturn]] inline void formula_band_bound_out_of_range() + { + std::abort(); + } +} // namespace detail +``` + + and the body: + +```cpp + std::optional const lowTop = detail::narrow_to_int64(lowBound.numerator()); + std::optional const lowBottom = detail::narrow_to_int64(lowBound.denominator()); + std::optional const highTop = detail::narrow_to_int64(highBound.numerator()); + std::optional const highBottom = detail::narrow_to_int64(highBound.denominator()); + if (!lowTop || !lowBottom || !highTop || !highBottom) + detail::formula_band_bound_out_of_range(); + return { *lowTop, *lowBottom, *highTop, *highBottom }; +``` + + Add to its comment: "A bound beyond 64 bits fails to compile, naming `formula_band_bound_out_of_range`." + - **`lookup.hpp:1330-1335`**, `breakpoint(Rational)`: the same, with `formula_breakpoint_key_out_of_range` and the locals `keyTop`, `keyBottom`. + - **`trace.hpp`, `point_in`**: + +```cpp + std::optional const keyTop = narrow_to_int64(stated->numerator()); + std::optional const keyBottom = narrow_to_int64(stated->denominator()); + if (!keyTop || !keyBottom) + return std::nullopt; + return Breakpoint { *keyTop, *keyBottom }; +``` + + - **`trace.hpp`, `unit_quotient`**: narrow `magnitude->numerator()` and `->denominator()` the same way, `return std::nullopt` when either does not fit, and use the narrowed values in `quotientUnit`. + - **`rounded_root.hpp`, `rounded_square_root_in`**: narrow `unitFactor`'s and `factorSquared`'s numerator and denominator, and `return std::unexpected { ArithmeticError::Overflow };` when any does not fit. + +- [ ] **Step 14: Run the new tests, then Verify.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "checked-int|wide-int|rational"`, then Verify step 1. Both must print `ALL OK`. Delta: **+4 tests**. Every census figure, gallery line and checked guide is unchanged, so no regeneration happens in this task. If `docs.numeric-headroom` or `gallery.is-current` fails, a reader's behaviour changed: find it and fix it. + +- [ ] **Step 15: Commit.** + Message: `refactor: read Rational's integers through helpers that hold 128 bits`. + Body: no behaviour change; the readers, the census tally and the wide integers now carry 128-bit magnitudes, ready for a wider `Rational::Int`. Then the `Signed-off-by` line. + +### Task 6: `Rational` over `Int128`, and the census re-measured + +Switch `Rational::Int` to `Int128`, widen `rounded_sqrt` to 128 bits, re-measure the overflow census, and pin the issue's cases, which now answer. + +**Files:** +- Modify: `include/formula-cpp/rational.hpp`: `Int`, `make`, `from_double_exact`, `to_double`, `checked_negate`, `checked_add`, `checked_mul`, `exact_integer_root`, `checked_exact_nth_root`; a constructor from `Int`; the file comment. +- Modify: `include/formula-cpp/rounded_root.hpp`: `mul_unsigned_or_none`, `unsigned_pow10`, `rounded_square_root` move to `UInt128`; `integer_square_root` is replaced by `u128_isqrt`. +- Modify: `support/census_tally.cpp` (`signed_bits_used` out of 127), `support/census_report.cpp` ("of 127"), `cmake/CheckCensusPage.cmake:77` ("of 127"). +- Modify: `test/overflow_census_tests.cpp`, `tools/census/exact_sizes.py`, `CMakeLists.txt:209,213`. +- Regenerate: `docs/numeric-headroom.md`'s tables. +- Modify: `examples/opaque_and_retry.cpp` and its guide `docs/opaque-and-retry.md`, plus `examples/CMakeLists.txt:280`, so the fit still refuses. +- Modify: every test the switch breaks. See Step 7. +- Modify: `test/package/main.cpp` and `tools/gallery/main.cpp`, where they store `numerator()` or `denominator()` in a built-in integer. +- Create: `test/negative/band_bound_out_of_range.cpp`, `test/negative/breakpoint_key_out_of_range.cpp`. +- Test: `test/rational_tests.cpp`, `test/statistics_tests.cpp`, `test/lookup_tests.cpp`. + +**Interfaces:** +- Consumes: everything from Tasks 4 and 5. +- Produces: + - `Rational::Int` is `formula::Int128`; + - `Rational(Int whole)`; + - `std::numeric_limits` holds the bounds; no `detail::IntMax` / `IntMin` is used for `Rational`'s range any more. + +- [ ] **Step 1: Write the failing tests.** Add to `test/rational_tests.cpp` (add `#include ` and `#include ` if absent): + +```cpp +TEST_CASE("a Rational holds 128-bit numerators and denominators", "[rational]") +{ + using formula::Int128; + using formula::Rational; + constexpr Int128 largest = std::numeric_limits::max(); + constexpr Int128 smallest = std::numeric_limits::min(); + STATIC_REQUIRE(std::is_same_v); + // The minimum is a numerator; its negation, absolute value and + // reciprocal are not representable, and are refused. + constexpr auto lowest = Rational::make(smallest, 1); + STATIC_REQUIRE(lowest.has_value()); + STATIC_REQUIRE(lowest->numerator() == smallest); + STATIC_REQUIRE(formula::checked_negate(*lowest).error() == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(formula::checked_abs(*lowest).error() == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(formula::checked_reciprocal(*lowest).error() == formula::ArithmeticError::Overflow); + // One past the largest is refused where the 64-bit sum used to be. + STATIC_REQUIRE(formula::checked_add(Rational { largest }, Rational { 1 }).error() == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(formula::checked_add(Rational { std::numeric_limits::max() }, Rational { 1 }).has_value()); +} + +TEST_CASE("the longest numbers a Rational holds are spelled in full", "[rational][number-text]") +{ + using formula::Int128; + using formula::Rational; + constexpr Int128 largest = std::numeric_limits::max(); + CHECK(formula::fraction_text(*Rational::make(std::numeric_limits::min(), 1)).view() + == "-170141183460469231731687303715884105728"); + CHECK(formula::fraction_text(*Rational::make(largest, largest - 1)).view() + == "170141183460469231731687303715884105727/170141183460469231731687303715884105726"); + auto const thirdOfLargest = formula::checked_decimal_text(*Rational::make(largest, 3), formula::DecimalPlaces { 18 }, + formula::RoundingMode::HalfEven, formula::DecimalPadding::Trimmed); + REQUIRE(thirdOfLargest.has_value()); + CHECK(thirdOfLargest->view() == "56713727820156410577229101238628035242.333333333333333333"); +} +``` + + Add to `test/statistics_tests.cpp`. Its fixtures name a mass quantity and a variance quantity in grams squared; use those names: + +```cpp +TEST_CASE("the variance of six masses read to the microgram answers exactly", "[statistics]") +{ + // The sample that overflowed 64 bits in kg^2: in g^2 its variance is + // 2026588050217/6000000000000, exactly. + auto const sixAtMicrograms = formula::measured_series( + formula::Rational { 40053270, 1000000 }, formula::Rational { 39475922, 1000000 }, formula::Rational { 39025798, 1000000 }, + formula::Rational { 40615904, 1000000 }, formula::Rational { 39418416, 1000000 }, formula::Rational { 40131659, 1000000 }); + auto const variance = formula::checked_evaluate(formula::sample_variance(formula::series), + formula::environment(sixAtMicrograms)); + REQUIRE(variance.has_value()); + REQUIRE(variance->is_value()); + CHECK(variance->measurement().value() == formula::Rational { 2026588050217, 6000000000000 }); +} +``` + + Build the environment the way `statistics_tests.cpp`'s existing cases do. The census test's `series_environment(...)` (`test/overflow_census_tests.cpp:240`) shows one way. + + Create `test/negative/band_bound_out_of_range.cpp`: + +```cpp +// SPDX-License-Identifier: Apache-2.0 +// A Band's bounds are 64-bit pairs, so that it stays a template argument; a +// bound beyond 64 bits fails to compile, naming the guard. +#include +#include + +constexpr formula::Band tooWide = + formula::band(formula::Rational { formula::Int128 { 1 } << 70 }, formula::Rational { formula::Int128 { 1 } << 71 }); +``` + + and `test/negative/breakpoint_key_out_of_range.cpp` the same way, with `formula::breakpoint(formula::Rational { formula::Int128 { 1 } << 70 })` from ``. Register both with `formula_add_negative_test`, expecting `formula_band_bound_out_of_range` and `formula_breakpoint_key_out_of_range`. Follow the protocol in *Execution*, including the deletion check. + +- [ ] **Step 2: Run them to see them fail.** + Run: `pwsh -NoProfile -File $S\cl.ps1 -Tree $T -Filter "rational|statistics"`. Expected: a build failure (`std::is_same_v`, and `Rational { Int128 }`). + +- [ ] **Step 3: Switch `rational.hpp`.** + - Add `#include `. + - The file comment: "An exact rational number over std::int64_t." becomes "An exact rational number over `formula::Int128`." + - `using Int = detail::Int;` becomes: + +```cpp + /// The signed integer numerator and denominator are stored in: 128 bits + /// (`int128.hpp`). + using Int = Int128; +``` + + - The integer constructor keeps its constraint. Its `static_assert` can no longer fire, since every built-in integer up to 64 bits fits `Int128`. Remove it, and its comment's paragraph about unsigned types as wide as `Int`. Replace them with: "Every built-in integer type fits `Int` exactly." If a negative test pins that `static_assert` (`grep -r "can hold values above Rational's maximum" test`), delete the test and its registration, and say so in the report: what used to be refused is now exact. + - Add, after it: + +```cpp + /// An `Int`, exactly. + constexpr Rational(Int whole) noexcept: + _numerator { whole } + { + } +``` + + - The template constructor's initialiser becomes `_numerator { whole }`. + - `make` (`:97-125`): + +```cpp + [[nodiscard]] static constexpr std::expected make(Int dividend, Int divisor) noexcept + { + if (divisor == 0) + return std::unexpected { ArithmeticError::DivisionByZero }; + FORMULA_CENSUS_NOTE(Numerator, detail::magnitude(dividend)); + FORMULA_CENSUS_NOTE(Denominator, detail::magnitude(divisor)); + if (dividend == 0) + return Rational {}; + + // Reduce in the unsigned domain so that the minimum is an ordinary operand. + detail::UInt128 const numeratorMagnitude = detail::magnitude(dividend); + detail::UInt128 const denominatorMagnitude = detail::magnitude(divisor); + detail::UInt128 const common = detail::gcd(numeratorMagnitude, denominatorMagnitude); + detail::UInt128 const reducedNumerator = detail::u128_divmod(numeratorMagnitude, common).quotient; + detail::UInt128 const reducedDenominator = detail::u128_divmod(denominatorMagnitude, common).quotient; + + bool const negative = (dividend < 0) != (divisor < 0); + + constexpr detail::UInt128 PositiveLimit = detail::magnitude(std::numeric_limits::max()); + detail::UInt128 const numeratorLimit = + negative ? detail::u128_add(PositiveLimit, detail::UInt128::from_u64(1)) : PositiveLimit; + if (PositiveLimit < reducedDenominator || numeratorLimit < reducedNumerator) + return std::unexpected { ArithmeticError::Overflow }; + + Rational made {}; + made._numerator = detail::signed_from_magnitude(reducedNumerator, negative); + made._denominator = detail::signed_from_magnitude(reducedDenominator, false); + return made; + } +``` + + - `from_double_exact`: + - `if (shifted >= 63)` becomes `if (shifted >= 127)`, and `if (-shifted >= 63)` becomes `if (-shifted >= 127)`; + - `auto scaledReduced = static_cast(reduced);` becomes `Int scaledReduced { reduced };`; + - `Int { 1 } << shifted` stays, now an `Int128` shift. + + A double whose exact value needs 64 to 126 bits now answers. + - `to_double` returns `_numerator.to_double() / _denominator.to_double();`. + - `checked_negate`: `operandValue.numerator() == std::numeric_limits::min()`. + - `checked_add`: `common` becomes: + +```cpp + Rational::Int const common = detail::signed_from_magnitude( + detail::gcd(detail::magnitude(leftOperand.denominator()), detail::magnitude(rightOperand.denominator())), false); +``` + + Its "Known limitation" paragraph says "both numerators near 2^127 over denominators sharing a large factor", and drops the sentence about 128-bit intermediates. + - `checked_mul`: `leftCross` and `rightCross` become `detail::signed_from_magnitude(detail::gcd(detail::magnitude(...numerator()), detail::magnitude(...denominator())), false)`. + - `checked_exact_nth_root`: + - `radicand.numerator() == detail::IntMin` becomes `== std::numeric_limits::min()`; + - `degree >= 63` becomes `degree >= 127`, and its comment says "2^127 alone exceeds the largest `Int`". Without this a 2^64 numerator at degree 64, now representable, would be called `Inexact`: a wrong refusal. + - `rational_from_spelling` keeps its 64-bit `Int` mantissa: the `_r` literal's caps do not change. + +- [ ] **Step 4: `rounded_root.hpp` in 128 bits.** + - `mul_unsigned_or_none` takes and returns `UInt128`: + +```cpp + [[nodiscard]] constexpr std::optional mul_unsigned_or_none(UInt128 leftFactor, UInt128 rightFactor) noexcept + { + std::optional const product = u128_mul_checked(leftFactor, rightFactor); + if (product) + FORMULA_CENSUS_NOTE(Unsigned, *product); + return product; + } +``` + + - `unsigned_pow10(std::int64_t exponent) -> std::optional` is `exponent < 0 || exponent > 38 ? std::nullopt : u128_pow10(static_cast(exponent))`. + - Delete `integer_square_root`. + - In `rounded_square_root`, from `auto const wholeNumerator` (`:191`) to `keptDigits` (`:252`): + +```cpp + UInt128 const wholeNumerator = wide_magnitude(radicandInUnitSquared.numerator()); + UInt128 const wholeDenominator = wide_magnitude(radicandInUnitSquared.denominator()); + auto const doubledPlaces = std::int64_t { 2 } * places.value; + + // v * S = wholePart + leftover / divisor. + UInt128 wholePart {}; + UInt128 leftover {}; + UInt128 divisor = wholeDenominator; + if (doubledPlaces >= 0) + { + std::optional const powerOfTen = unsigned_pow10(doubledPlaces); + if (!powerOfTen) + return std::unexpected { ArithmeticError::Overflow }; + UInt128Division const split = u128_divmod(wholeNumerator, wholeDenominator); + std::optional const scaledWhole = mul_unsigned_or_none(split.quotient, *powerOfTen); + // Below wholeDenominator * scale, so it fits whenever that does. + std::optional const scaledPart = mul_unsigned_or_none(split.remainder, *powerOfTen); + if (!scaledWhole || !scaledPart) + return std::unexpected { ArithmeticError::Overflow }; + UInt128Division const partSplit = u128_divmod(*scaledPart, wholeDenominator); + wholePart = u128_add(*scaledWhole, partSplit.quotient); + if (wholePart < *scaledWhole) + return std::unexpected { ArithmeticError::Overflow }; + FORMULA_CENSUS_NOTE(Unsigned, wholePart); + leftover = partSplit.remainder; + } + else + { + std::optional const shrink = unsigned_pow10(-doubledPlaces); + std::optional const widened = shrink ? mul_unsigned_or_none(wholeDenominator, *shrink) : std::nullopt; + if (!widened) + return std::unexpected { ArithmeticError::Overflow }; + divisor = *widened; + UInt128Division const split = u128_divmod(wholeNumerator, divisor); + wholePart = split.quotient; + leftover = split.remainder; + } + + std::uint64_t const floorDigits = u128_isqrt(wholePart); + // f^2 + f is below (f + 1)^2 <= 2^128, so it fits. + UInt128 const halfwayWhole = u128_add(u128_mul_words(floorDigits, floorDigits), UInt128::from_u64(floorDigits)); + bool const aboveHalfway = halfwayWhole < wholePart + || (wholePart == halfwayWhole && u128_divmod(divisor, UInt128::from_u64(4)).quotient < leftover); + + UInt128 keptDigits = UInt128::from_u64(floorDigits); + UInt128 const raisedDigits = u128_add(keptDigits, UInt128::from_u64(1)); + switch (roundingMode) + { + case RoundingMode::Floor: + case RoundingMode::TowardZero: + break; + case RoundingMode::Ceiling: + case RoundingMode::AwayFromZero: + keptDigits = raisedDigits; + break; + case RoundingMode::HalfAwayFromZero: + case RoundingMode::HalfTowardZero: + case RoundingMode::HalfEven: + keptDigits = aboveHalfway ? raisedDigits : keptDigits; + break; + } + + // At most 2^64, which `Rational::Int` holds. + return Rational::from_decimal(signed_from_magnitude(keptDigits, false), -places.value); +``` + + - In the doc comment (`:166-174`), "Every intermediate is a `std::uint64_t`; there is no 128-bit integer, because cl has none" becomes "Every intermediate is a 128-bit unsigned integer (`detail::UInt128`)". + - In the same comment, "2^64" becomes "2^128"; "an integer radicand of about 10^6 fits at 6 places and overflows at 7" becomes "fits at 16 places and overflows at 17"; and "1.8 * 10^(19 - 2|p|)" becomes "3.4 * 10^(38 - 2|p|)". + +- [ ] **Step 5: The census measures 127 bits.** + - `support/census_tally.cpp`: `signed_bits_used` returns `std::min(127, ...)`, with the comment "`Rational::Int`'s minimum, -2^127, uses every bit there is, and no more". + - `census_tally.hpp`: "what is left of 127 is the headroom". + - `support/census_report.cpp`: the line reads `... headroom {} of 127` and computes `127 - formula_census::signed_bits_used()`. + - `cmake/CheckCensusPage.cmake:77`: `headroom ([0-9]+) of 127`. + - In `test/overflow_census_tests.cpp`: + 1. `Used::headroom()` computes from 127 instead of 63. + 2. The instrument's control (`:670-704`) is rebuilt at 128 bits: + +```cpp +TEST_CASE("the census reports 0 bits of headroom for the largest Int128, and Overflow one step further", "[census]") +{ + constexpr formula::Int128 largest = std::numeric_limits::max(); + // (2^126 - 1) + 2^126 = 2^127 - 1 from two 126- and 127-bit operands, + // built outside the count: the sum's 127 bits are the addition's own + // intermediate, which only add_checked_or_none's hook reports. + Rational const lowHalf { (formula::Int128 { 1 } << 126) - 1 }; + Rational const highHalf { formula::Int128 { 1 } << 126 }; + Used const atTheLimit = + census_of([&] { REQUIRE(formula::checked_add(lowHalf, highHalf).value() == Rational { largest }); }); + CHECK(atTheLimit.headroom() == 0); + CHECK(atTheLimit.intermediateBits == 127); + // 2^63 * 2^62 = 2^125, one bit short of using all 127: the product is + // mul_checked_or_none's intermediate, from operands of 64 and 63 bits. + Rational const factorA { formula::Int128 { 1 } << 63 }; + Rational const factorB { formula::Int128 { 1 } << 62 }; + Used const oneShort = census_of( + [&] { REQUIRE(formula::checked_mul(factorA, factorB).value() == Rational { formula::Int128 { 1 } << 125 }); }); + CHECK(oneShort.headroom() == 1); + CHECK(oneShort.intermediateBits == 126); + // One step further is the library's Overflow, never a figure: a product + // that overflows leaves the count with its operands' 65 bits at most. + CHECK(formula::checked_add(Rational { largest }, rat(1)).error() == formula::ArithmeticError::Overflow); + Rational const tooWideA { formula::Int128 { 1 } << 64 }; + Rational const tooWideB { formula::Int128 { 1 } << 63 }; + Used const overflowed = census_of( + [&] { REQUIRE(formula::checked_mul(tooWideA, tooWideB).error() == formula::ArithmeticError::Overflow); }); + CHECK(overflowed.intermediateBits <= 65); + CHECK(overflowed.numeratorBits <= 65); + // A constant evaluation tells the census nothing. + Used const constant = census_of([] { + constexpr auto added = formula::checked_add(Rational { 1 << 20 }, Rational { 1 << 20 }); + static_assert(added.has_value()); + }); + CHECK(constant.headroom() == 127); +} +``` + + 3. The named realistic case (`:777-783`) now answers: + +```cpp + // The named realistic case: six masses at micrograms, which overflowed 64 + // bits in kg^2, now answer exactly. + auto const named = formula::checked_evaluate(formula::sample_variance(formula::series), + series_environment(sixAtMicrograms)); + REQUIRE(named.has_value()); + REQUIRE(named->is_value()); + CHECK(named->measurement().value() == Rational { 2026588050217, 6000000000000 }); + auto const namedRejection = + formula::checked_evaluate_rejection(rejection_of<6>(sevenQuarters), series_environment(sixAtMicrograms)); + CHECK(namedRejection.has_value()); +``` + + 4. The regression pins (`:823-842`): re-measure on cl-debug and replace the three headroom figures, and the numerator and unsigned bit counts, with the new measurements, keeping the "less 4 bits" margin. The comment says they were measured "at the commit that stores `Rational` in 128 bits". + 5. The cylinder pins (`:855-934`): no diameter overflows now. Pin that list as empty, and pin the strength at 139 mm to exactly `Rational { 13976660729400, 2375042831981 }` MPa. + 6. The least-squares and regression pins (`:985`, `:1047`): replace them with the new measured sizes. +- [ ] **Step 6: The exact companion sizes against 128 bits.** In `tools/census/exact_sizes.py`: + - The module comment says 128 bits where it says 64, and "a signed 128-bit integer's 127". + - `fits` becomes: + +```python +LIMIT = (1 << 127) - 1 + + +def fits(value): + """Whether an exact value is a Rational over formula::Int128: |numerator| + and the denominator at most 2^127 - 1 (the numerator may also be -2^127).""" + return abs(value.numerator) <= LIMIT and value.denominator <= LIMIT +``` + + - `main` also tracks the widest SI value, and prints exactly these lines: + +``` +six masses near 40 g at 4 dp: the exact variance does not fit 128 bits in 0 of 1000 in kg2 (SI; widest 52 bits), in 0 of 1000 in g2 (declared; widest 32 bits) +six masses near 40 g at 5 dp: the exact variance does not fit 128 bits in 0 of 1000 in kg2 (SI; widest 59 bits), in 0 of 1000 in g2 (declared; widest 39 bits) +six masses near 40 g at 6 dp: the exact variance does not fit 128 bits in 0 of 1000 in kg2 (SI; widest 65 bits), in 0 of 1000 in g2 (declared; widest 45 bits) +4F / (pi * d^2), F = 89.3 kN, d = 101 to 163 mm: in Pa (SI) the exact strength does not fit 128 bits at 0 of 63 (widest 64 bits) +4F / (pi * d^2), F = 89.3 kN, d = 101 to 163 mm: in MPa (declared) it does not fit at 0 of 63 (widest 44 bits) +``` + + - The self-check's output does not change. + - `CMakeLists.txt:213`'s `PASS_REGULAR_EXPRESSION` becomes: + `"at 6 dp: the exact variance does not fit 128 bits in 0 of 1000 in kg2 \\(SI. widest 65 bits\\), in 0 of 1000 in g2 \\(declared. widest 45 bits\\)[\r\n]+[^\r\n]*in Pa \\(SI\\) the exact strength does not fit 128 bits at 0 of 63 \\(widest 64 bits\\)[\r\n]+[^\r\n]*in MPa \\(declared\\) it does not fit at 0 of 63 \\(widest 44 bits\\)"`. + - If `docs/numeric-headroom.md`'s `census:exact` block is parsed by a regex in `cmake/CheckCensusPage.cmake`, update that regex to the new line shape. + +- [ ] **Step 7: Make the suite build, then re-pin what moved.** Build with Verify step 1. The switch turns every remaining narrowing into a compile error. Fix each site the way Task 5 fixed its kind: `wide_magnitude`, `narrow_to_int64`, `rational_int_from_magnitude`, `to_int64()`. Never use a cast. This includes `test/package/main.cpp` and `tools/gallery/main.cpp`. Then classify every failing test: + 1. **A refusal that now answers** (`Overflow` at a 64-bit bound): + - move the refusal to the 128-bit bound, so it is still tested; + - pin the new answer exactly, with its value worked out by hand or in Python; + - say so in the report. + + `test/rational_tests.cpp:314-347` is one: it names itself the test to flip. + 2. **A census figure** (`census.*`): Step 5 covers these. + 3. **A rounding or spelling limit stated in 64-bit terms**, e.g. `checked_round`'s `IntMax/3` case in `test/number_text_tests.cpp`: pin what the library now answers, if it now answers. + + **Any test whose answer changed from one number to another is a defect: stop and report it.** + +- [ ] **Step 8: Keep the least-squares example refusing.** `examples/opaque_and_retry.cpp` fits fifteen points with a different denominator on each, to show a fit refusing with `Overflow`. `examples/CMakeLists.txt:280` pins it. + - Find the smallest number of such points, built the same way, at which `LinearLeastSquares::compute` now overflows. + - Change the example to that number. + - Update its pass regex and `docs/opaque-and-retry.md`'s text block and prose (the count of points). + +- [ ] **Step 9: Regenerate the census page and the gallery.** + - Build the target `formula-cpp-census-page`, and check with `git diff docs/numeric-headroom.md`: + - the resolution table reads `0 of 1000` overflowed everywhere; + - the cylinder table lists no diameter; + - every realistic row keeps at least 8 bits. + - Regenerate the gallery and confirm with `git diff docs/gallery.md` that it is unchanged. A change there means a value's text moved: report it. + +- [ ] **Step 10: Verify.** + - Verify step 1 must print `ALL OK`. Delta: **+3 tests**, plus or minus the flipped and removed ones, each named in the report. + - Verify step 2 with `-Filter "band_bound_out_of_range|breakpoint_key_out_of_range|band_|breakpoint_|rational_"`. Delta: **+2 negative**, minus any that Step 3 removed. + +- [ ] **Step 11: Commit.** + Message: `feat: store Rational's numerator and denominator in 128 bits`. + The body says: + - the 6 dp variance, the rejection by standard deviations and the cylinder strength now answer, with the census's new figures; + - the `_r` literal, `from_decimal` and rounding keep their limits; + - `rounded_sqrt` works in 128 bits; + - narrowing into the 64-bit structural types is refused, not cut. + + Then the `Signed-off-by` line. + +### Task 7: The documentation of a 128-bit `Rational` + +**Files:** +- Modify: `docs/numeric-headroom.md`'s prose. Its tables were regenerated in Task 6. +- Modify: `docs/numbers.md:119`, `docs/calculations.md:987`, `docs/display.md:253`, `docs/statistics.md:357`, and the README wherever it states the integer width. +- Modify these doc comments: + - `include/formula-cpp/number_text.hpp`: the `IntMax/3` example at `:394-402`, and `ExactDecimalScale`'s "the largest power of ten `Rational::Int` holds"; + - `include/formula-cpp/function.hpp:146-148`: "-18 to 18" becomes "-38 to 38"; + - `include/formula-cpp/rounded_transcendental.hpp:46` and any other comment that states a 64-bit range as `Rational`'s; + - `include/formula-cpp/detail/checked_int.hpp`'s file comment. +- Modify: `CHANGELOG.md`. + +- [ ] **Step 1: Find every stale statement.** + Run: `Select-String -Path include\formula-cpp\*.hpp, include\formula-cpp\detail\*.hpp, docs\*.md, README.md -Pattern '64-bit|63 bits|2\^63|INT64|int64_t.*Rational|10\^18, the largest|IntMax'`. + Each hit is either about `Rational`'s range, and is rewritten for 128 bits, or about something genuinely 64-bit (`Unit`'s fields, the `_r` literal's mantissa, `DecimalPlaces`' 18), and stays. List both kinds in the report. + +- [ ] **Step 2: Rewrite the headroom page's prose** around its regenerated tables: + - **"The answer":** enough for every realistic case measured, with the 6 dp variance, the rejection and the cylinder figures from the tables. + - **Remove the paragraphs that left the choice open.** + - **Replace "Whether 128-bit intermediate arithmetic is enough…"** with a short section on what was chosen and why: + - 128-bit intermediates alone could not have helped: `checked_mul` already reduces before it multiplies, so an overflowing product is an overflowing result; + - the values are stored in SI, where the variances and strengths needed 64 bits or more; + - so the stored integer was widened to 128 bits, native where the compiler has a 128-bit integer and portable `constexpr` elsewhere. + - **The rule for acting stays:** under 8 bits of headroom recommends wider arithmetic. + - **"Headroom"** is `127` minus the bits used, and `rounded_sqrt`'s unsigned intermediates have 128. + - **The stress-control table** reads 2^126 and 2^127. + - **"Which cases decide" and "What this does not decide"** describe the new state. + + Do not cite the issue as open. + +- [ ] **Step 3: CHANGELOG.** Under `## [Unreleased]`: + +```markdown +### Added + +- **`formula::Int128`** (`int128.hpp`), a signed 128-bit integer with one API on every compiler: the compiler's own + 128-bit integer computes where it has one (GCC, Clang), portable `constexpr` code everywhere else (cl, clang-cl). + It converts to no built-in integer implicitly or explicitly; `to_int64()` and `to_uint64()` say when a value does + not fit. `std::format` writes it in decimal. + +### Changed + +- **`Rational` stores its numerator and denominator in `formula::Int128`**, so `Rational::Int` is `Int128` and a + `Rational` is 32 bytes. Realistic laboratory statistics that overflowed 64 bits now answer: the sample variance of + masses read to 6 decimal places of a gram, rejection by standard deviations at that resolution, and a cylinder's + strength at every diameter measured (`docs/numeric-headroom.md`). Code that stored `numerator()` or `denominator()` + in a built-in integer must narrow with `to_int64()`. The `_r` literal, `Rational::from_decimal`'s exponents and + rounding's decimal places keep their limits of 18. +- `rounded_sqrt` computes in 128 bits, and answers at more places before it reports `Overflow`. +- `NumberTextCapacity` is 128, so that a 39-digit numerator over a 39-digit denominator fits a `NumberText`. +- `band(Rational, Rational)` and `breakpoint(Rational)` refuse a bound or key that does not fit their 64-bit fields: + in a constant expression it fails to compile, naming `formula_band_bound_out_of_range` or + `formula_breakpoint_key_out_of_range`. +``` + +- [ ] **Step 4: Verify.** Verify step 1 must print `ALL OK`. Delta: **+0 tests**. Also build the Doxygen target: `cmake --build --preset cl-debug --target formula-cpp-docs-api`. It must report no warnings, or report the CI Doxygen run in Task 8 if the local Doxygen is missing. + +- [ ] **Step 5: Commit.** + Message: `docs: describe Rational's 128-bit range, and the headroom it measures`. + Then the `Signed-off-by` line. + +--- + +## Task 8: Merge the lanes, verify everything, and open the pull request + +The controller runs this task, with `sdd-implementer` for fix rounds and `sdd-reviewer` for the whole-branch review. + +- [ ] **Step 1: Merge Lane B into the feature branch.** + In `D:\formula-cpp\.claude\worktrees\int128-and-trace-units`: `git merge --no-ff feature/int128-core`, then resolve: + - `CHANGELOG.md`: keep both lanes' entries. + - `docs/numeric-headroom.md`: take Lane B's, then regenerate it in Step 2. + - Any test file both lanes touched: keep both changes. + - `include/formula-cpp/trace.hpp` and `trace_render.hpp`: the lanes touched different functions. + +- [ ] **Step 2: Regenerate and run the Windows gate.** + - Build `formula-cpp-census-page`. + - Regenerate the gallery. + - Run Verify step 1, and Verify step 2 with `-Filter "."`, all negatives on both Windows compilers. + - Commit any regeneration as `docs: regenerate the census page and the gallery for both changes`. + +- [ ] **Step 3: The full matrix.** + - `pwsh -NoProfile -File $S\windows-matrix.ps1 -Tree $T` must print `MATRIX OK`: cl-debug, cl-release, clangcl-debug, clangcl-release. + - `wsl bash .../posix-matrix.sh --tree /mnt/d/formula-cpp/.claude/worktrees/int128-and-trace-units` must print `MATRIX OK`: gcc-release with g++-14, and clang-debug, clang-release and clang-ubsan. + - `wsl bash .../docs-pages.sh --tree ...` must print `DOXYGEN OK`, and `mkdocs build --strict` must succeed. + - Each failure goes to a fix round, `sdd-implementer` with the failing log, then this step again. + +- [ ] **Step 4: Whole-branch review.** + Run `sdd-reviewer` over `master..feature/int128-and-trace-units`, against both specs, this plan's Global Constraints and its Review Focus. Its findings go to fix rounds; re-review until it is clean. + +- [ ] **Step 5: Pull request.** + - Push `feature/int128-and-trace-units`. + - Open the PR with `contour-workflows:create-pr`. The title is "A unit on every computed trace value, and a 128-bit Rational". The body ends with: + + ``` + Closes #1 + Closes #2 + ``` + + - Drive CI to green, including macOS AppleClang and install-and-consume. Its `test/package/main.cpp` was adapted in Task 6. + - **Ask the owner before merging.** Merge as a merge commit, which closes both issues. diff --git a/docs/superpowers/specs/2026-10-03-int128-rational-design.md b/docs/superpowers/specs/2026-10-03-int128-rational-design.md new file mode 100644 index 00000000..5d112144 --- /dev/null +++ b/docs/superpowers/specs/2026-10-03-int128-rational-design.md @@ -0,0 +1,194 @@ +# A 128-bit `Rational` over `formula::Int128` — design + +**Status:** draft, ready for review · **Date:** 2026-10-03 · **Owner:** Christian Parpart + +Resolves issue #1 ("Numeric headroom: 64-bit Rational overflows on realistic lab statistics"). It lands in one pull +request with the trace-units change (`2026-10-03-trace-units-design.md`). The two designs share no code. + +## 1. Problem + +`Rational` stores a 64-bit numerator and denominator (`rational.hpp:54`, `:282-283`). Every operation is exact and +refuses overflow as `ArithmeticError::Overflow`, so nothing returns a wrong number. But realistic laboratory data +runs out of range, and `docs/numeric-headroom.md` measures where: + +- the sample variance of six masses near 40 g at 6 decimal places overflows on 423 of 1000 samples; +- rejection by 7/4 standard deviations overflows on 897 of 1000 at 6 dp; +- a cylinder's strength 4F/(πd²) at 89.3 kN overflows at 17 of 63 diameters between 101 and 163 mm; +- a least-squares line through readings at 3 decimals overflows from 34 points. + +The page's own rule, "any realistic case under 8 bits of headroom recommends wider arithmetic", is met. + +## 2. Options weighed + +An exact model of `checked_add` and `checked_mul`, written in Python over the census's own samples, reproduces +today's figures exactly: 423 of 1000 variance overflows, 17 of 63 cylinder diameters. Run against the alternatives: + +| Representation | variance, 6 dp | rejection, 6 dp | cylinder | variance, 10 dp | +|---|---|---|---|---| +| today: 64-bit | 423/1000 overflow | ~890/1000 | 17/63 | — | +| 128-bit intermediates, 64-bit storage | at most 49 rescued | — | 0 rescued | — | +| n/d × 10^e (a decimal exponent) | fits, 10 bits spare | fits, 10 bits | fits, 12 bits | overflows from 8 dp | +| **`Rational` over a 128-bit integer** | **fits, 62 of 127 bits spare** | **fits, 58** | **fits, 63** | **fits, 35** | + +**Intermediates alone cannot help a product.** `checked_mul` cross-reduces before it multiplies +(`rational.hpp:355-380`), so its product is already in lowest terms. When that product overflows 64 bits, the exact +result does not fit 64 bits either. Only `checked_add` can overflow before it reduces. + +**Declared-unit storage was rejected.** Nodes would compute and record in a declared or scaled unit instead of SI. +That changes the evaluator's core rule (every leaf converted to SI), the meaning of `Step::value` and the renderer's +conversion, and a variance node has no declared unit to work in. + +**An opt-in wide `Rep` was rejected.** The traced and audited path hard-codes `Rational`: `checked_evaluate`, +`Outcome`, `explain`, `Step`, 32 `is_same_v` gates and the rejection loop. An opt-in type +would leave every one of those at 64 bits. + +**Decision:** a `formula::Int128` integer, native where the compiler has one and constexpr software where it does +not. `Rational` is built on it, and there is no 64-bit `Rational` beside it. + +## 3. `formula::Int128` — `include/formula-cpp/int128.hpp` + +A signed 128-bit two's-complement integer: one class, with one API on every compiler. + +### Storage and arithmetic + +**One layout everywhere:** two `std::uint64_t` words in two's complement. A second, native layout would double the +class for no gain: a conversion in and out of the compiler's own integer optimises away. + +| Compiler | Multiplication, division, overflow check | Why | +|---|---|---| +| GCC, Clang, AppleClang: `__SIZEOF_INT128__` defined, `_MSC_VER` not | `unsigned __int128`, through `__extension__ typedef` | Hardware arithmetic. `__extension__` keeps `-Wpedantic` quiet under `CMAKE_CXX_EXTENSIONS OFF` | +| cl, and clang-cl: any `_MSC_VER` | portable `constexpr` code on the two words | cl has no 128-bit integer. clang-cl accepts `__int128`, but dividing one calls compiler-rt's `__divti3`, which the MSVC linker does not supply | + +- **The native type is never handed to the standard library.** In strict mode, libstdc++ does not treat `__int128` as + an integer type: no `std::is_integral_v`, `std::numeric_limits`, `std::make_unsigned` or `std::format`. +- **The portable operations are `constexpr` functions on two words, in `detail::`.** Every compiler builds and tests + them. No intrinsics are used, so constant evaluation and run time take the same code. +- **A 64-bit fast path** applies to division, remainder and gcd: when both operands fit 64 bits, the 64-bit operation + runs. Nearly every value a formula forms is small, so this keeps run time close to today's. It also keeps cl's + constexpr step budget safe for the suite's roughly 2730 `STATIC_REQUIRE`s, and avoids a call to `__udivti3` on the + native path. +- **Determinism is unaffected.** Integer results are identical on every path, so the rule that the same inputs give + the same digits on every compiler still holds. This updates the declared-precision design's "no intrinsics, no + `__int128`" rule (`2026-09-29-declared-precision-design.md:40-41`), which existed to protect exactly that property. + +### Surface + +| Group | Members | +|---|---| +| Construction | `constexpr` default (zero); implicit `constexpr` from every built-in integer type except `bool`, sign-extending signed and zero-extending unsigned values | +| Arithmetic | `+ - * / %`, unary `-`, and their compound forms; `<<`, `>>` | +| Comparison | `==`, `<=>` (`std::strong_ordering`) | +| Conversion | **none to a built-in integer type, implicit or explicit**, so code written for a 64-bit `Rational` cannot cut a 128-bit value in half; `to_int64()` and `to_uint64()` return `std::optional`; `fits_int64()`; `to_double()`, rounded to nearest, ties to even | +| Standard library | a `std::numeric_limits` specialisation; `std::formatter`, decimal only. No `std::hash`: `Rational` has none | + +- **Plain operators behave like a built-in signed integer:** overflow, division by zero and an out-of-range shift are + preconditions. +- **Checked forms live in `detail/checked_int.hpp`,** beside the 64-bit ones, as `Int128` overloads of + `add_checked_or_none`, `sub_checked_or_none` and `mul_checked_or_none`. Natively they use `__builtin_add_overflow` + and its kin, which are `constexpr` on GCC and Clang. In software they use partial products. +- **`detail::UInt128`**, an unsigned 128-bit type, carries magnitudes. A numerator equal to the minimum, -2^127, keeps + working, as `std::uint64_t` magnitudes let -2^63 work today. It also carries `gcd` (binary, using `std::countr_zero` + on the words) and the integer square root. + +### Tests — `test/int128_tests.cpp` + +- **Every operation** at 0, ±1, the 64-bit boundaries (±2^63, 2^64) and the 128-bit boundaries (±(2^127 − 1), -2^127). +- **Compile time and run time:** `STATIC_REQUIRE` and `CHECK` on the same cases. +- **Software against native:** the software path is cross-checked against the native one on GCC and Clang, over + fixed tables and a seeded generator. On cl and clang-cl, where there is no native type, it is checked against a + 64-bit oracle for products of 32-bit halves, which `test/checked_int_tests.cpp:99-122` already builds. +- **Checked operations** report overflow exactly at the boundary, never one before or after it. +- **Formatting and conversions:** `std::format`, `to_double`, `numeric_limits`. + +## 4. `Rational` over `Int128` + +- **Type and layout.** `Rational::Int` becomes `formula::Int128`, so `numerator()` and `denominator()` return it. + The bounds become 2^127 − 1 and −2^127, and `sizeof(Rational)` goes from 16 to 32 bytes. +- **Algorithms.** They are unchanged: `make`'s reduction (now over `UInt128` magnitudes), cross-reduced `checked_mul`, + `checked_add`, the continued-fraction `<=>`, `checked_negate` refusing the minimum, and `checked_pow`. +- **Constructors.** The integer constructor accepts every built-in integer type except `bool`; the unsigned 64-bit + `static_assert` no longer applies, because `std::uint64_t` fits `Int128`. A constructor from `Int128` is added. +- **Unchanged documented limits.** Rounding places stay −18…18, and so do `from_decimal`'s exponent range and the + `_r` literal's digit and exponent caps. Only the integer width changes. Widening those limits is a separate decision. + The 64-bit `detail::pow10` stays for them; a 128-bit `pow10`, up to 10^38, serves the code that spells a 128-bit + integer. +- **`detail::Int` keeps meaning `std::int64_t`** for every use that is not `Rational`'s integer. + +**Compatibility rule.** Every computation that answers today gives the same answer afterwards; some that are +refused with `Overflow` today now answer. A kernel that assumed 64-bit inputs either widens or refuses a wider input +with `Overflow`, never a different number. + +### Readers of `numerator()` / `denominator()` + +There are about 159 uses in 22 files. Each one is moved to `Int128` arithmetic, or narrowed through `to_int64()` +with an `Overflow` refusal where its algorithm is genuinely 64-bit. + +| File | Change | +|---|---| +| `rounding.hpp` | decimal scaling and `at_least_pow10` | +| `number_text.hpp` | spelling a 128-bit integer in decimal | +| `detail/wide_rounding.hpp` | `wide_from_rational` splits 128 bits into four 32-bit limbs; `narrow_wide_ratio` narrows to 128 bits, so more exact fits answer | +| `rounded_root.hpp` | the integer square root moves from `std::uint64_t` to `UInt128` | +| `function.hpp`, `critical_value.hpp`, `detail/transcendental.hpp`, `rounded_transcendental.hpp`, `lookup.hpp`, `band.hpp`, `trace.hpp`, `detail/least_squares_kernel.hpp` | the remaining readers | + +`RepTraits::from` converts through `Int128::to_double()`, and `Rational::to_double()` does the same. + +## 5. The census and the headroom page + +- **Headroom is `127 − bits`.** + - The hooks (`FORMULA_CENSUS_NOTE`, `census_record`) take a 128-bit magnitude. + - `support/census_tally` and `support/census_report` keep and print 128-bit figures ("headroom N of 127"). + - The regex at `cmake/CheckCensusPage.cmake:77` and those at `CMakeLists.txt:209,213` follow. +- **Pins are rebuilt.** + - The stress controls in `test/overflow_census_tests.cpp` move to the 128-bit boundaries, e.g. (2^126 − 1) + 2^126 + = 2^127 − 1 with headroom 0, and (2^127 − 1) + 1 being `Overflow`. + - The cylinder list, the resolution survey and the least-squares and regression pins take their new values. + - `tools/census/exact_sizes.py` sizes against 127 bits, and `census.exact-sizes` and its self-check pin the new + output. +- **The page is regenerated** with `formula-cpp-census-page`. Its prose is rewritten: + - *The answer* becomes "enough, for every realistic case measured", with the figures. + - The 8-bit rule stays, as the rule a future regression would be judged by. + - The section that left the choice open is replaced by what was chosen and why: the table in §2, in prose. +- **`examples/opaque_and_retry`** shows a least-squares fit refusing with `Overflow` on distinct denominators (pinned + at `examples/CMakeLists.txt:280`). It moves to data that still outgrows 128 bits, so it keeps teaching the refusal. + +## 6. Tests and documentation + +**Tests:** + +- **Flipped.** `test/rational_tests.cpp:314-347` names itself "the one to flip" when wide intermediates arrive. +- **Moved to the 128-bit bounds.** Tests that pin `Overflow` at 64-bit bounds, in `rational_tests`, `checked_int_tests` + and the overflow stress tests. Each keeps pinning the refusal at the new limit, not merely stops failing. +- **New cases.** The issue's named sample (40.053270, 39.475922, 39.025798, 40.615904, 39.418416, 40.131659 g) now + has a variance and a rejection by 7/4 standard deviations. The cylinder strength answers at 139 mm. +- **Unchanged.** Negative tests that pin the literal's caps, since those limits do not change. + +**Documentation:** + +- Every guide that says 64-bit says 128-bit: `docs/numbers.md:119`, `docs/calculations.md:987`, `docs/display.md:253`, `docs/statistics.md:357`, and the README where it states the width. +- The `Rational` and `Int128` doc comments; Doxygen builds clean. +- CHANGELOG `[Unreleased]`: + - *Added:* `formula::Int128`. + - *Changed:* `Rational::Int` is `formula::Int128` (a source change for code that stores `numerator()` in an + `std::int64_t`), values are twice the size, and `Overflow` arrives much later. + +## 7. Verification + +**Per task:** + +- MSVC `cl-debug`: the full build and every non-negative test. +- The task's negative tests on `cl-debug` and `clangcl-debug`. + +**The `Int128` task** also runs `gcc-release` and `clang-debug` under WSL. The native path exists only there. + +**At the end**, all eight presets, Doxygen and mkdocs, and the census page: + +- no overflow in the variance or the rejection at 6 dp; +- the cylinder fits at every diameter; +- every realistic row keeps at least 8 bits. + +## 8. Out of scope + +- **Wider documented limits:** rounding places, `from_decimal` exponents and `_r` digits beyond 18. +- **Faster wide kernels.** `detail/wide_int.hpp`'s one-bit-at-a-time division stays as it is. +- **A 64-bit `Rational` for memory-constrained use.** diff --git a/docs/superpowers/specs/2026-10-03-trace-units-design.md b/docs/superpowers/specs/2026-10-03-trace-units-design.md new file mode 100644 index 00000000..fce6c114 --- /dev/null +++ b/docs/superpowers/specs/2026-10-03-trace-units-design.md @@ -0,0 +1,192 @@ +# Units on every computed trace value — design + +**Status:** draft, ready for review · **Date:** 2026-10-03 · **Owner:** Christian Parpart + +Resolves issue #2 ("Trace: arithmetic on single values prints bare coherent-SI numbers with no unit"). It lands +in one pull request with the `Int128` change (`2026-10-03-int128-rational-design.md`). The two designs share no +code. + +## 1. Problem + +A trace step holds its value in the coherent SI unit (`Step::value`, `trace.hpp:982-988`), and shows it in +`Step::unit`. Every step whose kind has no declared unit is given `coherent(N::dimension)` (`trace.hpp:3124`). That +unit has no symbol, so the renderer prints the number with nothing after it (`value_in_declared_unit`, +`trace_render.hpp:1495-1520`, appends a symbol only when there is one). + +Take the outlier-rejection example in `docs/statistics.md`: + +```text +2. 3/50 +3. pass mean = 413/10 g +4. #2 * #3 = 1239/500000 +... +6. rejected element 4 of 6 (44 g) in pass 1: abs(x - mean) = 27/10 g > 1239/500 g (deviation from mean) +``` + +Line 4 is 2.478 g, printed in kilograms with no unit. Two lines later the same value is `1239/500 g`. The number +is right, but a reader cannot tell what it is or check it by eye. + +The same happens to every step kind that computes a value: + +- the four binary operators; +- negation, `abs`, powers and roots; +- conditionals and precision limits, which re-state a value another step computed; +- sample variance; +- sums, ranges and means, whose borrowing gives up on an offset unit or a unit without a symbol. + +Series steps already show a readable unit: they borrow their operand step's unit through +`detail::operand_unit_or`, under the safety rule `detail::borrowable` (`trace.hpp:2118-2156`). Single values never +got the same treatment. + +## 2. The rule + +> **No computed value prints without a unit.** A step shows its value in a unit read off its operand steps when +> that is unambiguous and safe. Otherwise it shows the coherent unit and writes that unit's symbol. + +Two principles carry over from the series code unchanged: + +- **The decision is read off the operand steps, never off C++ types.** What an operand step shows is what carries + over, so a step never claims a unit its operands did not show. +- **`detail::borrowable` is the safety rule.** It never borrows a unit with an offset (°C, °F) or a unit without a + symbol. A product, sum or difference of offset readings is not a point on that scale, and would be off by the + offset. A step whose value *is* one operand's value (a pass-through) may show any unit with a symbol, offset + included, under `detail::borrowable_for_a_point`, as a mean already does. + +## 3. Recording — `RecordingSink::produced` (`trace.hpp`) + +Each rule applies only when the claimed operand steps are provably the node's own: + +- each side records a step of its own (`detail::RecordsOwnStep`); +- the run-time operand count is what the rule expects; +- the borrowed unit's dimension equals the step's. + +Otherwise the coherent unit stands, and §4 gives it a symbol. This is the guard set the series block already uses +(`trace.hpp:3856-3872`). + +| Step | Shows | Condition | +|---|---|---| +| `x * k`, `k * x`, `x / k` (scaling by a pure number) | `x`'s step unit | `k`'s dimension is dimensionless and `x`'s is not; `x`'s unit is `borrowable`. Both sides dimensionless: no unit is chosen, as for series | +| `x + y`, `x - y` | the shared unit | both operand steps show the same scale and symbol: dimension, magnitude, offset and symbol equal, and `borrowable`. The shown decimals are the larger of the two | +| `-x`, `abs(x)` | `x`'s step unit | `x`'s unit is `borrowable` | +| a conditional | the chosen branch's step unit | the branch step was claimed and its value is the conditional's value; `borrowable_for_a_point` | +| a precision limit | the re-stated step's unit | the step it re-states was claimed and holds its value; `borrowable_for_a_point` | +| everything else (products and quotients of dimensioned values, powers, roots, variance, mixed units, offset sums and differences) | the coherent unit | unchanged; §4 writes its symbol | + +Notes: + +- **Scaling** extends `detail::scaled_operand` (`trace.hpp:2225-2241`) from `ElementwiseBinaryNode` to `BinaryNode`. + `BinarySides` already exists (`trace.hpp:2169-2174`). +- **"Same scale and symbol" is a new predicate**, not `Unit::operator==`. That operator also compares decimals and + bounds (`unit.hpp:86-87`), and two gram readings declared with different decimals are still both grams. The + borrowed unit is the left operand's, with its decimals raised to the larger of the two, so the sum is never shown + less precisely than an operand. +- **°C − °C is not shown in °C.** `borrowable` refuses offset units, so the difference of two Celsius readings shows + the coherent unit, `K`, which is what a temperature interval is. +- **Steps that already copy their operand's unit pick up the new units automatically:** `Documented`, + `ReplacedVariant`, `VariantSelected`, `RecordScope`, and opaque outputs through `opaque_output_unit`. + +## 4. Rendering — the coherent symbol (`trace_render.hpp`) + +When a step's unit has no symbol and its dimension is not dimensionless, `value_in_declared_unit` writes the +number, then `" "`, then `detail::coherent_unit_text(dimension)` (`trace_render.hpp:2473-2526`). That helper already +exists, is already pinned (`test/opaque_tests.cpp:1044-1074`), and writes base units in a fixed order with carets: +`kg`, `kg^2`, `m/s`, `m^2`, `kg/(m s^2)`, `K`, `EUR`, `EUR/JPY`, `m^(1/2)`. A dimensionless value stays a bare number. + +- **The symbol is never stored in `Step::unit`.** `Symbol` holds 16 characters, terminator included, and + `EUR s^2/(m^2 kg)` alone is 16. `detail::is_unlabelled` must also stay true for a coherent unit, so number styling + does not change: such a value is still never padded, and its approximation still extends to the first significant + digit. +- **`opaque_value_text`** (`trace_render.hpp:2528-2543`) drops its own append, which this generalises, so the unit + is not written twice. +- **Series elements and statistic lines** go through the same function, so they are labelled too. +- **Worksheet headers** (`block_value_text`) already use a declared unit, so they are unchanged. + +## 5. What changes for a reader + +| Today | After | +|---|---| +| `4. #2 * #3 = 1239/500000` | `4. #2 * #3 = 1239/500 g` | +| `#1 - #2 = 108000000` (kWh − kWh) | `#1 - #2 = 30 kWh` | +| `fridge_kw * fridge_h = 17280000` | `fridge_kw * fridge_h = 17280000 m^2 kg/s^2` | +| `sample_variance(#1) = 427/125000000` | `sample_variance(#1) = 427/125000000 kg^2` | +| `if #1 > #2 then #3 = 60000000` (MPa branch) | `if #1 > #2 then #3 = 60 MPa` | +| `#1 - #2 = 5` (°C − °C) | `#1 - #2 = 5 K` | +| `#3 / #6 = 134/1185` (dimensionless) | unchanged | + +A value shown in a borrowed unit is styled the way that unit's declared values are: padded to its decimals under a +padded style, and rounded to them under an approximating one. The display guide's `≈0.004` (a bare product in kg) +becomes a gram value at the gram's declared decimal. + +## 6. Tests + +**One pinned case per acceptance criterion of issue #2:** + +1. The rejection example prints `#2 * #3 = 1239/500 g`. +2. A sum of two gram values prints in grams. A difference of two °C readings prints in `K`, not °C. +3. A product of two lengths prints in `m^2`, never as a bare number. +4. No step prints a number in a unit different from the one it is shown with. A test helper walks every step of + several traces: the rejection example, the electricity bill, a statistics trace, a conditional, a precision limit, + and an opaque call. For each step it: + - renders the step in the exact fraction style; + - splits off the value and the unit text; + - requires the unit text to be the step's own symbol, or `coherent_unit_text` when the step has none; + - requires `checked_convert(value, shown unit, coherent unit)` to equal `Step::value`. + + The helper parses `a/b` itself: the only text-to-`Rational` parser, `rational_from_spelling`, is `consteval`. + +**Rule tests, each fixture shaped so a mutation of its guard fails:** + +- scaling on the left and on the right; +- `x / k`, and `k / x` (not scaling); +- both sides dimensionless; +- g + g with different declared decimals; +- g + kg (mixed: coherent); +- °C − °C; +- negation and `abs` of a gram value and of a Celsius reading; +- a conditional over an MPa branch and over a Celsius branch; +- a precision limit; +- a forwarding consumer node, whose operands are not its own sides: coherent. + +**Re-pinned expectations.** About 290 lines in tests and guides show bare numbers today. Every dimensioned one is +updated to the new text; dimensionless ones are unchanged. The largest sets: + +| File | Lines | +|---|---| +| `test/calculation_trace_tests.cpp` | 38 | +| `test/trace_render_tests.cpp` | 32 | +| `test/vocabulary_tests.cpp` | 27 | +| `test/retry_tests.cpp` | 21 | +| `test/rejection_tests.cpp` | 21 | +| `test/precision_tests.cpp` | 13 | + +`examples/display.cpp:208` pins a bare tare difference, which now borrows grams. + +## 7. Documentation + +**Regenerated or checked against the examples:** + +- `docs/gallery.md` is regenerated by its generator, and `gallery.is-current` holds it. +- Every ```` ```text ```` block in a checked guide follows its example's output (`docs.*-output`). +- Guides whose blocks no test checks are updated by hand: tracing, rounding-and-conditionals, lookup-tables, + expressions, constraints, calculations, records and methods-and-overlays. + +**Prose that states the old rule is rewritten:** + +- `docs/display.md` "A value in a unit nobody declared" (`:161-218`, `:359-373`). Its dish and tare examples now + borrow grams, so the section moves to an example that is still coherent, such as a product of lengths, and keeps + teaching the unpadded, first-significant-digit styling. +- `docs/tracing.md:296-345`, `docs/dimensions.md:447-455`, `docs/statistics.md:110-112`, `docs/series.md:106-113`, + `docs/calculations.md:597-599`, `docs/expressions.md:608-612`, `docs/lookup-tables.md:748-752`, + `docs/rounding-and-conditionals.md:259-264`, `docs/methods-and-overlays.md:86-90`. +- The `Step::unit` doc comment (`trace.hpp:970-997`, quoted in `docs/tracing.md:308-325`), the recording comment + (`trace.hpp:3093-3117`), and `trace_render.hpp:25-39` and `:104-123`. +- Test comments that state the old rule, e.g. `trace_render_tests.cpp:101-104`. +- CHANGELOG `[Unreleased]`, *Changed*: computed trace values show a unit, borrowed where safe and coherent otherwise. + +## 8. Out of scope + +- **Named derived units** (`Pa`, `N`, `J`) for coherent values. `coherent_unit_text` writes base units, and choosing + a named unit is a separate decision. +- **Borrowing through products or powers** (g × g → g², mm × mm → mm²). A composed symbol needs a unit algebra over + symbols that the library does not have; the coherent symbol is correct and checkable. +- **Any change to `Step::value`**, which stays the coherent SI value. diff --git a/docs/tracing.md b/docs/tracing.md index 792fecd3..56706745 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -299,26 +299,47 @@ metres the arithmetic actually runs on. Every `Step` stores its value in the **coherent unit** of its dimension (the SI unit, times one of each [named base dimension](dimensions.md#base-dimensions-the-si-does-not-have) it carries) -- the one scale every step's value can be compared on -- but also -remembers the unit it was *declared* in, and `render_trace` converts back -before printing. `Step`'s own comment explains why the recorder, not the -renderer, has to be the one holding that unit: +remembers the unit it is shown in, and `render_trace` converts back before +printing and writes that unit after the number. `Step`'s own comment states +which unit that is, and why the recorder, not the renderer, has to be the one +holding it: ```cpp -/// The unit this step's value was **declared** in -- `Describe::unit` -/// for a variable or an overridden constant, the constant's own unit for -/// a constant, the node's own unit for a `Round`, `RoundSignificant`, -/// `RoundedRoot` or `RoundingRuleApplied` step, the unit of the step it -/// wraps for a `Documented`, `ReplacedVariant` or `VariantSelected` step -- -/// each passes its operand's value through unchanged, so it states it as -/// that operand's line does, whenever that line is the wrapped node's own -/// and not the operands of a consumer's node -- and the coherent unit of -/// `dimension` for anything else computed, which has no declared unit of -/// its own. +/// The unit this step's value is shown in: +/// +/// - a variable, constant or rounding shows its declared unit -- +/// `Describe::unit` for a variable or an overridden constant, the +/// constant's own unit for a constant, the node's own unit for a +/// `Round`, `RoundSignificant`, `RoundedRoot`, `RoundedOpaqueOutput` or +/// `RoundingRuleApplied` step; +/// - a `Documented`, `ReplacedVariant`, `VariantSelected` or `RecordScope` +/// step passes its operand's value through unchanged, so it shows the +/// unit that operand's line does, whenever that line is the wrapped +/// node's own and not the operands of a consumer's node; +/// - a value scaled by a pure number shows its operand's unit, and so +/// does a sum or difference on one scale under one name, a series' sum +/// and its range; +/// - a negation and an absolute value show their operand's unit; +/// - a mean and a rejection pass's mean are points on their sample's +/// scale, and show its unit, offset or not; +/// - a conditional and a precision limit show the unit of the step they +/// restate; +/// - an opaque operation's output shows an input's unit of its +/// dimension, or a quotient of two (`OpaqueOutputValue::unit`); +/// - an offset unit is never borrowed for a sum, difference, scaling, +/// negation or absolute value: such a value is no point on its scale; +/// - everything else is the coherent unit of `dimension`, which the +/// renderer writes after the number, spelt from its bases (`kg/m^3`). +/// +/// A unit is borrowed only from operand steps that are provably the +/// operands' own, and only when it has a symbol: a value in a unit with +/// no symbol could not say what scale it is on, and reads in the coherent +/// unit instead. /// /// `value` is always in the coherent unit, so that steps are /// comparable; this is what a renderer converts back to before showing a /// number to a person. Without it a derivation restates every input in a -/// unit nobody typed: someone who entered 180 l reads `9/50`, which is +/// unit nobody typed: someone who entered 180 l reads `9/50 m^3`, which is /// the same volume and a worse record. The renderer cannot recover this /// on its own -- by the time a `Step` exists the quantity type is erased, /// so the recorder captures it here. @@ -329,34 +350,47 @@ walking that quantity's own node; by the time `RecordingSink::produced` builds a `Step` for it, the type is gone and only the runtime `Unit` value survives. Capturing anything less at that point -- the coherent unit alone, say -- would make `render_trace` unable to ever show `180 l` again; it would show -`9/50 m3`, arithmetically identical and a strictly worse record of what +`9/50 m^3`, arithmetically identical and a strictly worse record of what someone actually typed. -A step that is a plain computation, `#1 / #2` above, carries no declared unit -of its own -- it's whatever the coherent unit of its dimension is, which -`test/trace_render_tests.cpp` pins directly for a squared mass over a volume: +A step that is a plain computation, `#1 / #2` above, has no declared unit of +its own. Where the steps it read say which unit it is in, it borrows theirs, by +the rules below; otherwise it is in the coherent unit of its dimension, and +`render_trace` writes that unit after the number, spelt from its base units. +`test/trace_render_tests.cpp` pins the second case directly for a squared mass +over a volume: ``` 1. m = 6 kg -2. #1^2 = 36 +2. #1^2 = 36 kg^2 3. V = 3 m3 -4. #2 / #3 = 12 +4. #2 / #3 = 12 kg^2/m^3 ``` (`test/trace_render_tests.cpp`, `"a derivation renders one line per step, in -order"`.) `#1^2` and `#2 / #3` carry no unit symbol at all -- and the reason is -not that `kg2` and `kg2/m3` are awkward to spell. `coherent()` -(`evaluate.hpp`) hands **every** computed step a `Unit` with no symbol at all, -whatever its dimension: a computed *mass* prints no `kg` either, nor a -computed length its `m`. A compound dimension is simply the case where the -absence is most obvious, since there is no everyday symbol to miss; the -behaviour itself applies to anything the evaluator computed rather than -declared, with these exceptions, each of which takes its unit off a step it -read: +order"`.) `kg^2` and `kg^2/m^3` are no symbols anyone declared. `coherent()` +(`evaluate.hpp`) hands a computed step a `Unit` with no symbol at all, and the +renderer spells such a unit from the SI base units -- `m`, `kg`, `s`, `A`, `K`, +`mol`, `cd` -- with the name of each named base dimension ahead of them: +`kg/(m s^2)` for a pressure, `EUR` for a price per kilowatt-hour times an +energy. A computed mass reads `kg`, a computed length `m`. Only a +dimensionless value is a bare number: `#1 / #2` above, a ratio of two volumes, +reads `3/5`. A value declared in a unit of the author's own that has no symbol +reads in the coherent unit too, converted, since its number alone could not +say what scale it is on. + +A computed step borrows its unit off the steps it read in these cases: - A step that passes a value on unchanged -- a documented step, a jurisdiction's replacement, a variant's selection, a read from another record -- states it in the unit of the step it wraps, below. +- A value scaled by a pure number reads in its operand's unit -- 3/50 of a + mean of 413/10 g is `#2 * #3 = 1239/500 g` -- and so does a sum or a + difference of two values shown on one scale under one name, at the finer of + their two precisions. +- A negation and an absolute value read in their operand's unit, and a + conditional in its chosen branch's: `if #1 > #2 then #3 = 60 MPa`. A + precision limit reads in its second pass's. - A value that is a point on its operand's scale -- a mean, a pass's mean, a rejected determination -- reads in that operand's unit when it has a symbol, offset or not: a mean of Celsius readings is a Celsius reading. @@ -369,6 +403,13 @@ read: - An opaque output reads in an input's unit, or a quotient of two, under the same rule ([Opaque operations and bounded retry](opaque-and-retry.md)). +An offset unit is never borrowed for a sum, a difference, a scaling, a +negation or an absolute value: such a value is no point on its scale. The +difference of two Celsius readings is an interval, and reads `#1 - #2 = 5 K`, +not `5 °C`. Nor is a unit with no symbol borrowed, and nor is one read off a +step that is not provably the operand's own: over a consumer's node that hands +the sink on to its operands (below), the step reads in the coherent unit. + A binary step whose left operand failed never evaluated its right one, and says so where the right operand would stand: @@ -396,7 +437,8 @@ it documents does"`.) A jurisdiction's replaced variant is the same: its line reads as the replacement's own. Over a consumer's node that hands the sink on to its operands (see below), there is no line of the node's own to read as -- only its operands', none of which holds its value -- so the documented step -states its value in the coherent unit, as any computed step does. +states its value in the coherent unit, as a computed step with no unit to +borrow does. A step that failed shows why instead of a value, and a step with no value at all -- an absent measurement, which is not an error -- says so rather than @@ -436,7 +478,7 @@ std::print("{}", formula::render_trace(derived.trace, { .maxSteps = 20 })); ``` 1. F = 562 kN 2. 19321 mm2 -3. #1 / #2 = 562000000000/19321 +3. #1 / #2 = 562000000000/19321 kg/(m s^2) 4. round(#3, in MPa) = 291/10 MPa [rounded to 1 dp (method default); nearest, ties away from zero] 5. #4 = 291/10 MPa [variant Cube (1st of 3), selected by tag] ``` diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index f99ed1f7..59cca1d2 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -267,7 +267,7 @@ add_test(NAME docs.records-snippets # in the order the program prints them: an opaque call's line saying its # inside is not shown, with its citation; the page's operation entry; two # outputs as two runs; the exact slope; a degenerate fit's and an overflowing -# fit's refusals; the slope rounded where it is used, and on fifteen distinct +# fit's refusals; the slope rounded where it is used, and on twenty-seven distinct # denominators; an uncited call; the retry's rendering; its six endings, # each by its concluded line; the attempt-1 mistake; and the two-sided # agreement accepted at the third attempt; a line through observations, exact @@ -277,7 +277,7 @@ add_test(NAME docs.records-snippets # kelvin and per percent, with a design with one regressor twice another # refused. No literal `;`: # PASS_REGULAR_EXPRESSION is a list property (see the lookup_tables entry). -formula_add_example(opaque_and_retry opaque_and_retry.cpp [==[series span\(r\(i\)\)\.span.*span = 88 g \[inside not shown\] \[Spread of readings, Example Standard 12, 4\.2\].*operation: series span, outputs: lowest highest span.*operation runs: 2.*slope = 19/28 mm/s \[inside not shown\].*one point: argument outside the domain of the operation.*fifteen distinct denominators: overflow in exact arithmetic.*round\(linear least squares\(t\(i\), L\(i\)\)\.slope, to 4 dp of mm/s\).*4\. linear least squares\(#3\) = intercept, slope: rounded where used \[inside not shown\].*5\. round\(slope of #4, to 4 dp of mm/s\) = 3393/5000 mm/s \[nearest, ties to even\].*fifteen distinct denominators, rounded where used: 116\.232 mm/min.*\[inside not shown\] \(no citation given\).*up to 4 attempts: w\(k\) = 152/25 g \+ w\(k-1\) / 2, starting from w\(0\) = 0 g.*accepted at attempt 4 of 4 = 57/5 g.*exhausted after 3 of 3: repeat the determination.*not judgeable at attempt 1.*d_a = retry: attempt 3 not recorded.*failed at attempt 1: division by zero.*manually entered after 0 attempt\(s\).*w\(k-1\) = previous attempt: none before attempt 1.*accept from attempt 2 when abs\(d_a\(k\) - d_a\(k-1\)\) <= 127/100 g.*accepted at attempt 3 of 4 = 427/10 g.*linear least squares\(#1, #2\) = intercept = 19/2 mm. slope = 19/28 mm/s. r squared = 1083/1085. points = 4 \[inside not shown\].*rounded where used.*r squared at 4 dp, floored, at least 0\.998: satisfied.*flat lengths: argument outside the domain of the operation.*fifty readings at 4 decimals, exact: overflow in exact arithmetic.*fifty readings at 4 decimals, rounded: slope 3\.1707 mm/s, intercept 2406\.6455 mm, r squared 0\.999996.*multiple least squares\(#1, #2, #3\) = constant = 22365154943/276592800 mm.*coefficient 1: 0\.0751 mm/K, coefficient 2: 0\.5557 mm/%, length at 0 degrees Celsius: 101\.39 mm.*a delay twice the elapsed time on every row: argument outside the domain of the operation.*all checks passed: yes]==]) +formula_add_example(opaque_and_retry opaque_and_retry.cpp [==[series span\(r\(i\)\)\.span.*span = 88 g \[inside not shown\] \[Spread of readings, Example Standard 12, 4\.2\].*operation: series span, outputs: lowest highest span.*operation runs: 2.*slope = 19/28 mm/s \[inside not shown\].*one point: argument outside the domain of the operation.*twenty-seven distinct denominators: overflow in exact arithmetic.*round\(linear least squares\(t\(i\), L\(i\)\)\.slope, to 4 dp of mm/s\).*4\. linear least squares\(#3\) = intercept, slope: rounded where used \[inside not shown\].*5\. round\(slope of #4, to 4 dp of mm/s\) = 3393/5000 mm/s \[nearest, ties to even\].*twenty-seven distinct denominators, rounded where used: 122\.238 mm/min.*\[inside not shown\] \(no citation given\).*up to 4 attempts: w\(k\) = 152/25 g \+ w\(k-1\) / 2, starting from w\(0\) = 0 g.*accepted at attempt 4 of 4 = 57/5 g.*exhausted after 3 of 3: repeat the determination.*not judgeable at attempt 1.*d_a = retry: attempt 3 not recorded.*failed at attempt 1: division by zero.*manually entered after 0 attempt\(s\).*w\(k-1\) = previous attempt: none before attempt 1.*accept from attempt 2 when abs\(d_a\(k\) - d_a\(k-1\)\) <= 127/100 g.*accepted at attempt 3 of 4 = 427/10 g.*linear least squares\(#1, #2\) = intercept = 19/2 mm. slope = 19/28 mm/s. r squared = 1083/1085. points = 4 \[inside not shown\].*rounded where used.*r squared at 4 dp, floored, at least 0\.998: satisfied.*flat lengths: argument outside the domain of the operation.*fifty readings at 8 decimals, exact: overflow in exact arithmetic.*fifty readings at 8 decimals, rounded: slope 3\.1707 mm/s, intercept 2406\.6454 mm, r squared 0\.999996.*multiple least squares\(#1, #2, #3\) = constant = 22365154943/276592800 mm.*coefficient 1: 0\.0751 mm/K, coefficient 2: 0\.5557 mm/%, length at 0 degrees Celsius: 101\.39 mm.*a delay twice the elapsed time on every row: argument outside the domain of the operation.*all checks passed: yes]==]) # Every output block of docs/opaque-and-retry.md must be a run of lines the # example really prints, and every code block must appear in its source. diff --git a/examples/display.cpp b/examples/display.cpp index d9a98f46..cd1f2bb5 100644 --- a/examples/display.cpp +++ b/examples/display.cpp @@ -6,7 +6,8 @@ // 1. A trace of a soil specimen's moisture content, in the default // fractions, as exact decimals, rounded where no decimal ends, and padded // to each unit's declared decimals. -// 2. A value in a unit nobody declared, and a comparison, which no style +// 2. A value in the coherent unit, nobody's declared unit; a value in the +// unit it borrows from its operands; and a comparison, which no style // rounds. // 3. The formula's own text: its typed numbers as decimals, never rounded // and never padded. @@ -48,6 +49,12 @@ using DishWeighing = formula::Quantity; using OvenTemperature = formula::Quantity; using GrainSize = formula::Quantity; +using PlateLength = formula::Quantity; +using PlateWidth = formula::Quantity; +using PlateArea = formula::Quantity; +using Elongation = formula::Quantity; +using HoldTime = formula::Quantity; +using CreepRate = formula::Quantity; // ---- 1. The moisture content ----------------------------------------------------- // The water the specimen lost over its dry mass, the dish's typed 25.5 g taken off. @@ -58,6 +65,18 @@ inline constexpr auto specimen = formula::environment(formula::Measured { 157.4_r }, formula::Measured { 144 }); // ---- 2. A value in a unit nobody declared, and a comparison ------------------------ +// A bearing plate's area: a product of two lengths, which borrows neither one's unit. +inline constexpr auto plateArea = var * var; + +inline constexpr auto plate = + formula::environment(formula::Measured { 100 }, formula::Measured { 200 }); + +// A creep rate: a length over a time, which borrows neither one's unit. +inline constexpr auto creepRate = var / var; + +inline constexpr auto heldLoad = + formula::environment(formula::Measured { 2.4_r }, formula::Measured { 0.75_r }); + // The mean of three weighings: their sum times a typed 1/3, which has no exact decimal. inline constexpr auto dishMass = formula::sum(formula::series) * formula::number(Rational { 1, 3 }); @@ -155,6 +174,36 @@ int main() // ---- 2. A unit nobody declared, and a comparison --------------------------------- std::println("== 2. A unit nobody declared, and a comparison ==\n"); + auto const area = formula::checked_explain(plateArea, plate); + if (!area) + { + std::println("the plate's area: {}", area.error().error); + return 1; + } + check(area->outcome.is_value(), "the plate's area is a value"); + std::string const areaTraceText = formula::render_trace(area->trace, { .maxSteps = 20, .numbers = paddedStyle }); + std::println("{}", areaTraceText); + formula::NumberText const areaText = formula::number_text(area->outcome.measurement(), roundedStyle); + std::println("the plate's area in its declared square millimetres: {}\n", areaText.view()); + check(areaTraceText.contains("1. l_p = 100.0 mm\n") && areaTraceText.contains("3. #1 * #2 = 0.02 m^2\n"), + "a value in the coherent unit is not padded, where a value in millimetres is"); + check(areaText == "20000 mm2", "the declared result in square millimetres"); + + auto const creep = formula::checked_explain(creepRate, heldLoad); + if (!creep) + { + std::println("the creep rate: {}", creep.error().error); + return 1; + } + check(creep->outcome.is_value(), "the creep rate is a value"); + std::string const creepTraceText = formula::render_trace(creep->trace, { .maxSteps = 20, .numbers = paddedStyle }); + std::println("{}", creepTraceText); + formula::NumberText const creepText = formula::number_text(creep->outcome.measurement(), roundedStyle); + std::println("the creep rate in its declared millimetres per minute: {}\n", creepText.view()); + check(creepTraceText.contains("3. #1 / #2 = \xe2\x89\x88" "0.0000009 m/s\n"), + "a value in the coherent unit is rounded at its first significant digit"); + check(creepText == "\xe2\x89\x88" "0.05 mm/min", "the declared result rounds at its unit's two decimals"); + auto const dish = formula::checked_explain(dishMass, weighings); if (!dish) { @@ -205,7 +254,7 @@ int main() std::println("trace, padded style:\n{}", tareTraceText); check(tareFormula == "m_d - 24 g" && tareTraceText.contains("2. 24.0 g\n"), "a formula states the typed 24 g, a trace pads it"); - check(tareTraceText.contains("3. #1 - #2 = 0.12\n"), "a unit nobody declared is not padded"); + check(tareTraceText.contains("3. #1 - #2 = 120.0 g\n"), "a difference of two gram values is padded as grams are"); // ---- 4. number_text and decimal_text ------------------------------------------------ std::println("== 4. number_text and decimal_text ==\n"); diff --git a/examples/opaque_and_retry.cpp b/examples/opaque_and_retry.cpp index b30bea4a..d794696b 100644 --- a/examples/opaque_and_retry.cpp +++ b/examples/opaque_and_retry.cpp @@ -122,20 +122,20 @@ constexpr formula::DecimalRounding slopeRounding = formula::declared_rounding(millimetrePerSecond, formula::RoundingMode::HalfEven); constexpr auto roundedSlope = formula::rounded_output<"slope", slopeRounding>(fit); -/// Fifteen points, each on a different denominator: point k at +/// Twenty-seven points, each on a different denominator: point k at /// ((k + 1)/(k + 2) s, (2k + 3)/(k + 3) mm). auto distinctDenominators() { - std::array, 15> times; - std::array, 15> lengths; - for (std::size_t k = 0; k < 15; ++k) + std::array, 27> times; + std::array, 27> lengths; + for (std::size_t k = 0; k < 27; ++k) { auto const position = static_cast(k); times[k] = formula::Measured { formula::Rational { position + 1, position + 2 } }; lengths[k] = formula::Measured { formula::Rational { 2 * position + 3, position + 3 } }; } - return formula::environment(formula::MeasuredSeries { times }, - formula::MeasuredSeries { lengths }); + return formula::environment(formula::MeasuredSeries { times }, + formula::MeasuredSeries { lengths }); } // ---- 5. A retry ------------------------------------------------------------------------ @@ -235,8 +235,9 @@ constexpr auto observedPoints = formula::environment(formula::MeasuredObservations(1_r, 2_r, 4_r, 7_r), formula::MeasuredObservations(10.2_r, 10.9_r, 12.1_r, 14.3_r)); -/// Fifty readings at four decimals: t = k + 1 + (7919 k mod 997) / 10^4 s and -/// L = 2410 + 3.17 k + ((3217 k mod 1009) - 504) / 10^4 mm, for k from 0. +/// Fifty readings at eight decimals: t = k + 1 + (7919 k mod 997) / 10^4 +/// + (1237 k mod 10^4) / 10^8 s and L = 2410 + 3.17 k + ((3217 k mod 1009) +/// - 504) / 10^4 + (4111 k mod 10^4) / 10^8 mm, for k from 0. auto fiftyReadings() { std::array times; @@ -244,8 +245,13 @@ auto fiftyReadings() for (std::size_t k = 0; k < 50; ++k) { auto const position = static_cast(k); - times[k] = formula::Rational { 10'000 * (position + 1) + (7919 * position) % 997, 10'000 }; - lengths[k] = formula::Rational { 24'100'000 + 31'700 * position + (3217 * position) % 1009 - 504, 10'000 }; + times[k] = formula::Rational { + (10'000 * (position + 1) + (7919 * position) % 997) * 10'000 + (1237 * position) % 10'000, 100'000'000 + }; + lengths[k] = formula::Rational { + (24'100'000 + 31'700 * position + (3217 * position) % 1009 - 504) * 10'000 + (4111 * position) % 10'000, + 100'000'000 + }; } return formula::MeasuredObservations::from(times).and_then([&](auto const& timesMade) { return formula::MeasuredObservations::from(lengths).transform( @@ -351,22 +357,24 @@ int main() std::println("one point: {}", noLine.has_value() ? "a line" : formula::describe(noLine.error())); check(!noLine.has_value() && noLine.error() == formula::ArithmeticError::DomainError, "no line through one point"); - constexpr auto fifteen = formula::linear_least_squares( - formula::curve(formula::series, formula::series), { .reference = "Example Standard 12" }); - auto const tooWide = formula::checked_evaluate(formula::opaque_output<"slope">(fifteen), distinctDenominators()); - std::println("fifteen distinct denominators: {}", tooWide.has_value() ? "a line" : formula::describe(tooWide.error())); + constexpr auto twentySeven = formula::linear_least_squares( + formula::curve(formula::series, formula::series), { .reference = "Example Standard 12" }); + auto const tooWide = + formula::checked_evaluate(formula::opaque_output<"slope">(twentySeven), distinctDenominators()); + std::println("twenty-seven distinct denominators: {}", + tooWide.has_value() ? "a line" : formula::describe(tooWide.error())); check(!tooWide.has_value() && tooWide.error() == formula::ArithmeticError::Overflow, "Overflow, never a wrong line"); std::println("{}", formula::render(roundedSlope)); auto const roundedRate = formula::explain(roundedSlope, points); std::println("{}", formula::render_trace(roundedRate.trace, { .maxSteps = 20 })); check(formula::number_of(roundedRate.outcome) == 40.716_r, "0.6786 mm/s is 40.716 mm/min"); - constexpr auto roundedFifteen = formula::rounded_output<"slope", slopeRounding>(fifteen); - auto const roundedWide = formula::checked_evaluate(roundedFifteen, distinctDenominators()); - check(formula::number_of(roundedWide) == 116.232_r, - "rounded where used, fifteen distinct denominators answer: 1.9372 mm/s"); + constexpr auto roundedTwentySeven = formula::rounded_output<"slope", slopeRounding>(twentySeven); + auto const roundedWide = formula::checked_evaluate(roundedTwentySeven, distinctDenominators()); + check(formula::number_of(roundedWide) == 122.238_r, + "rounded where used, twenty-seven distinct denominators answer: 2.0373 mm/s"); if (roundedWide.has_value()) - std::println("fifteen distinct denominators, rounded where used: {}\n", *roundedWide); + std::println("twenty-seven distinct denominators, rounded where used: {}\n", *roundedWide); std::println("== 4. A citation is required ==\n"); @@ -474,7 +482,7 @@ int main() return 1; } auto const exactFifty = formula::checked_evaluate(observedLine, *fifty); - std::println("fifty readings at 4 decimals, exact: {}", + std::println("fifty readings at 8 decimals, exact: {}", exactFifty.has_value() ? "a line" : formula::describe(exactFifty.error())); auto const slopeOfFifty = formula::checked_evaluate(observedSlope, *fifty); auto const startOfFifty = formula::checked_evaluate( @@ -488,7 +496,7 @@ int main() *fifty); check(slopeOfFifty.has_value() && startOfFifty.has_value() && qualityOfFifty.has_value(), "fifty readings, rounded"); if (slopeOfFifty.has_value() && startOfFifty.has_value() && qualityOfFifty.has_value()) - std::println("fifty readings at 4 decimals, rounded: slope {}, intercept {}, r squared {}\n", + std::println("fifty readings at 8 decimals, rounded: slope {}, intercept {}, r squared {}\n", *slopeOfFifty, *startOfFifty, *qualityOfFifty); diff --git a/examples/series.cpp b/examples/series.cpp index d1836783..32b155c8 100644 --- a/examples/series.cpp +++ b/examples/series.cpp @@ -204,8 +204,8 @@ int main() std::string const meanLine = last_line( formula::render_trace(formula::trace_of(formula::sample_mean(threeReadings), readings), { .maxSteps = 80 })); std::println("the readings: {}\ntheir sum: {}\ntheir range: {}\ntheir mean: {}\n", readingsLine, sumLine, rangeLine, meanLine); - check(sumLine == "2. sum(#1) = 18447/20", "922.35 K, no reading"); - check(rangeLine == "2. sample_range(#1) = 88/5", "17.6 K, no reading"); + check(sumLine == "2. sum(#1) = 18447/20 K", "922.35 K, no reading"); + check(rangeLine == "2. sample_range(#1) = 88/5 K", "17.6 K, no reading"); check(meanLine == "2. sample_mean(#1) = 343/10 \xc2\xb0" "C", "a mean of readings is a reading, 34.3 degC"); std::println("== 2. Elementwise arithmetic: one step per operation ==\n"); @@ -214,8 +214,8 @@ int main() std::println("{}", passingTrace); check(passingTrace.contains("3. cumulative(#2, from last) = 803 g; 673 g; 463 g; 368 g; 28 g\n"), "the running total from the coarsest screen"); - // A computed step has no declared unit, so it reads in the coherent one: - // 447/1250 is 35.76 %. + // A percentage less a pure number is in two units, so it borrows neither + // and reads in the coherent one, a plain fraction: 447/1250 is 35.76 %. check(passingTrace.ends_with("6. #1 - #5 = 447/1250; 577/1250; 787/1250; 441/625; 611/625\n"), "35.76, 46.16, 62.96, 70.56 and 97.76 % passing"); std::println("the same, within a budget of 8:\n{}", formula::render_trace(passingRun.trace, { .maxSteps = 8 })); diff --git a/include/formula-cpp/band.hpp b/include/formula-cpp/band.hpp index a4049f2c..21b2e42d 100644 --- a/include/formula-cpp/band.hpp +++ b/include/formula-cpp/band.hpp @@ -77,6 +77,8 @@ #include #include #include +#include +#include #include #include @@ -111,11 +113,32 @@ struct Band return { lowNumerator, lowDenominator, highNumerator, highDenominator }; } +namespace detail +{ + /// A `band` bound that a `Band`'s `std::int64_t` numerator or denominator + /// cannot hold. Deliberately not `constexpr`: reaching it in a constant + /// expression fails to compile, naming it. At run time it ends the + /// program -- a `Band` is a template argument, built at compile time, + /// and has no way to carry a failure. + [[noreturn]] inline void formula_band_bound_out_of_range() + { + std::abort(); + } +} // namespace detail + /// Builds a `Band` from its low (inclusive) and high (exclusive) bound as -/// exact numbers: `band(83.7_r, 97.3_r)`, `band(0, 127)`. +/// exact numbers: `band(83.7_r, 97.3_r)`, `band(0, 127)`. A bound beyond 64 +/// bits fails to compile, naming `formula_band_bound_out_of_range`; reached at +/// run time, that guard ends the program. [[nodiscard]] constexpr Band band(Rational lowBound, Rational highBound) noexcept { - return { lowBound.numerator(), lowBound.denominator(), highBound.numerator(), highBound.denominator() }; + std::optional const lowTop = detail::narrow_to_int64(lowBound.numerator()); + std::optional const lowBottom = detail::narrow_to_int64(lowBound.denominator()); + std::optional const highTop = detail::narrow_to_int64(highBound.numerator()); + std::optional const highBottom = detail::narrow_to_int64(highBound.denominator()); + if (!lowTop || !lowBottom || !highTop || !highBottom) + detail::formula_band_bound_out_of_range(); + return { *lowTop, *lowBottom, *highTop, *highBottom }; } namespace detail diff --git a/include/formula-cpp/critical_value.hpp b/include/formula-cpp/critical_value.hpp index 724e9538..6a745c88 100644 --- a/include/formula-cpp/critical_value.hpp +++ b/include/formula-cpp/critical_value.hpp @@ -278,20 +278,21 @@ namespace detail UncheckedCorrections>; static_assert(sizeof(std::size_t) <= sizeof(std::uint64_t), - "formula: a sample size is compared as a 64-bit count, and this platform's std::size_t is wider"); + "formula: a table's sample size is widened through a 64-bit count to the 128 bits it is " + "compared as, and this platform's std::size_t is wider than 64 bits"); /// The row of @p Sizes whose size is @p sampleSize, or nothing. A linear /// scan, for `find_band`'s reason, and an equality: never the nearest row. /// - /// Compared as `std::uint64_t`, into which every `std::size_t` widens + /// Compared as 128 bits, into which every `std::size_t` widens /// losslessly: the count is never narrowed to the table's type, so where /// `std::size_t` is 32 bits a count of 2^32 + 3 misses rather than wrap /// onto the row for 3. template - [[nodiscard]] constexpr std::optional find_sample_size(std::uint64_t sampleSize) noexcept + [[nodiscard]] constexpr std::optional find_sample_size(UInt128 sampleSize) noexcept { for (std::size_t rowIndex = 0; rowIndex < Sizes.size(); ++rowIndex) - if (static_cast(Sizes[rowIndex]) == sampleSize) + if (UInt128::from_u64(static_cast(Sizes[rowIndex])) == sampleSize) return rowIndex; return std::nullopt; } @@ -299,12 +300,13 @@ namespace detail /// @p evaluatedCount as a sample size, or nothing when it is not a whole, /// non-negative number. The count is read in the coherent unit of its /// dimension, which is a bare number. A whole count larger than any size - /// a table can declare is still a count, and misses in `find_sample_size`. - [[nodiscard]] constexpr std::optional as_sample_size(Rational evaluatedCount) noexcept + /// a table can declare -- beyond 2^64 - 1 included -- is still a count, + /// and misses in `find_sample_size`. + [[nodiscard]] constexpr std::optional as_sample_size(Rational evaluatedCount) noexcept { if (evaluatedCount.denominator() != 1 || evaluatedCount.numerator() < 0) return std::nullopt; - return static_cast(evaluatedCount.numerator()); + return wide_magnitude(evaluatedCount.numerator()); } /// How many characters `sample_size_list` spells. @@ -453,7 +455,7 @@ template const sampleSize = detail::as_sample_size(**evaluatedCount); + std::optional const sampleSize = detail::as_sample_size(**evaluatedCount); std::optional const matchedRow = sampleSize.has_value() ? detail::find_sample_size(*sampleSize) : std::nullopt; if (!matchedRow.has_value()) diff --git a/include/formula-cpp/curve.hpp b/include/formula-cpp/curve.hpp index 7d938e5f..e3e372ef 100644 --- a/include/formula-cpp/curve.hpp +++ b/include/formula-cpp/curve.hpp @@ -30,12 +30,12 @@ /// extrapolation or a clamp. It is carried out in the coherent unit, since /// a computed domain has no declared unit of its own to carry it out in; a /// linear interpolation's answer does not depend on the scale either axis is -/// stated in. Its exact arithmetic can: a point that is exact in its declared -/// unit may not be in the coherent one. `domain` -/// fails with `Overflow` at its first element, since 1/10^19 m has a -/// denominator no int64 holds, where an interpolating lookup over the same -/// table -- which interpolates in its declared key unit -- answers. The -/// failure is conservative: it never gives a wrong number. +/// stated in. Its exact arithmetic can: a point's denominator grows on its +/// way to the coherent unit -- 1/10^16 mm is 1/10^19 m -- and a point or a sum +/// that leaves `Rational::Int`'s range fails with `Overflow`, where an +/// interpolating lookup over the same table, which interpolates in its +/// declared key unit, may answer. The failure is conservative: it never gives +/// a wrong number. /// /// **A splice** is the sorted union of two curves by domain, of static length /// `A::length + B::length`, whichever curve is written first. Two points at diff --git a/include/formula-cpp/detail/checked_int.hpp b/include/formula-cpp/detail/checked_int.hpp index 7bcba44c..2d82a41b 100644 --- a/include/formula-cpp/detail/checked_int.hpp +++ b/include/formula-cpp/detail/checked_int.hpp @@ -2,10 +2,22 @@ #pragma once /// @file -/// Overflow-detecting integer primitives, all constexpr and free of compiler -/// intrinsics. MSVC has no __builtin_*_overflow, and its equivalents -/// are not constexpr, so the checks are written in portable C++ and used on -/// every compiler. Optimisers recognise these idioms. +/// Overflow-detecting integer primitives, all constexpr, on two widths. +/// +/// - **128 bits**, for `Int128`, which `Rational` stores its numerator and +/// 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`). +/// - **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), or to the transcendental kernel's +/// 64-bit words (`narrow_to_int64`). 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 @@ -18,15 +30,18 @@ /// Then every integer these primitives form at run time -- and every /// numerator and denominator `Rational::make` is handed -- is reported to /// `census_record`, which the census program defines -/// (`support/census_tally.cpp`), so that it can say how many of the 63 bits -/// real formulas use (`docs/numeric-headroom.md`). A constant evaluation -/// reports nothing. Without the macro -- every build but the census's -- -/// `FORMULA_CENSUS_NOTE` expands to nothing, its arguments are never -/// evaluated, and none of the census's names exist: no call, no symbol, no -/// cost. +/// (`support/census_tally.cpp`), so that it can say how many of the bits +/// `Rational::Int` holds real formulas use (`docs/numeric-headroom.md`). A +/// constant evaluation reports nothing. Without the macro -- every build but +/// the census's -- `FORMULA_CENSUS_NOTE` expands to nothing, its arguments +/// are never evaluated, and none of the census's names exist: no call, no +/// symbol, no cost. + +#include #include #include +#include namespace formula::detail { @@ -54,7 +69,7 @@ inline constexpr Int IntMin = -IntMax - 1; #if defined(FORMULA_OVERFLOW_CENSUS) /// What an integer the overflow census is told of was: a numerator or a /// denominator handed to `Rational::make`, any other signed intermediate, or -/// an unsigned one (`rounded_sqrt`'s, which has 64 bits to use). +/// an unsigned one (`rounded_sqrt`'s, which has 128 bits to use). enum class CensusRole : std::uint8_t { Numerator, @@ -63,13 +78,13 @@ enum class CensusRole : std::uint8_t Unsigned, }; -/// Told the magnitude of an integer formed at run time. Declared here and -/// defined only by the census program, never by the library. -void census_record(CensusRole role, std::uint64_t magnitudeSeen) noexcept; +/// Told the magnitude of an integer formed at run time, as 128 bits. Declared +/// here and defined only by the census program, never by the library. +void census_record(CensusRole role, UInt128 magnitudeSeen) noexcept; /// Tells the overflow census of @p magnitudeSeen, unless this is a constant /// evaluation. -constexpr void census_note(CensusRole role, std::uint64_t magnitudeSeen) noexcept +constexpr void census_note(CensusRole role, UInt128 magnitudeSeen) noexcept { if !consteval { @@ -77,6 +92,12 @@ constexpr void census_note(CensusRole role, std::uint64_t magnitudeSeen) noexcep } } +/// Tells the overflow census of a 64-bit @p magnitudeSeen. +constexpr void census_note(CensusRole role, std::uint64_t magnitudeSeen) noexcept +{ + census_note(role, UInt128::from_u64(magnitudeSeen)); +} + /// Tells the overflow census that an integer of @p magnitudeSeen was formed /// in @p role (a `CensusRole` enumerator's name). See the file comment. #define FORMULA_CENSUS_NOTE(role, magnitudeSeen) \ @@ -213,4 +234,133 @@ struct DivMod return mul_checked_or_none(operandValue, *powerOfTen); } +// ---- 128 bits: `Int128`'s checked operations, and helpers that read +// `Rational::Int` whatever its width ------------------------------------------- + +/// @p operandValue's magnitude as 128 bits. +[[nodiscard]] constexpr UInt128 wide_magnitude(Int operandValue) noexcept +{ + return UInt128::from_u64(magnitude(operandValue)); +} + +/// @p operandValue's magnitude: 2^127 for the minimum. +[[nodiscard]] constexpr UInt128 wide_magnitude(Int128 operandValue) noexcept +{ + return magnitude(operandValue); +} + +/// @p operandValue as a 64-bit integer, which it always is. +[[nodiscard]] constexpr std::optional narrow_to_int64(Int operandValue) noexcept +{ + return operandValue; +} + +/// @p operandValue as a 64-bit integer, or nothing when it does not fit. +[[nodiscard]] constexpr std::optional narrow_to_int64(Int128 operandValue) noexcept +{ + return operandValue.to_int64(); +} + +/// The 64-bit integer whose two's complement bits are @p wordPattern's low +/// word. @pre the value fits: the high word is the low word's sign. +[[nodiscard]] constexpr Int int_from_pattern(UInt128 wordPattern, std::type_identity) noexcept +{ + // Well defined since C++20: conversion to a signed type is modular. + return static_cast(wordPattern.lowWord); +} + +/// The `Int128` whose two's complement bits are @p wordPattern. +[[nodiscard]] constexpr Int128 int_from_pattern(UInt128 wordPattern, std::type_identity) noexcept +{ + return Int128::from_words(wordPattern.highWord, wordPattern.lowWord); +} + +/// @p operandValue's two's complement bits. +[[nodiscard]] constexpr UInt128 pattern_of(Int128 operandValue) noexcept +{ + return UInt128 { operandValue.high_word(), operandValue.low_word() }; +} + +// The sum and the difference are formed on the bit patterns, which wrap, so +// that an overflow is detected without `Int128`'s own operators ever being +// handed a result that does not fit. + +[[nodiscard]] constexpr std::optional add_checked_or_none(Int128 leftOperand, Int128 rightOperand) noexcept +{ + Int128 const added = int_from_pattern(u128_add(pattern_of(leftOperand), pattern_of(rightOperand)), + std::type_identity {}); + if (leftOperand.is_negative() == rightOperand.is_negative() && added.is_negative() != leftOperand.is_negative()) + return std::nullopt; + FORMULA_CENSUS_NOTE(Intermediate, magnitude(added)); + return added; +} + +[[nodiscard]] constexpr std::optional sub_checked_or_none(Int128 leftOperand, Int128 rightOperand) noexcept +{ + Int128 const subtracted = int_from_pattern(u128_sub(pattern_of(leftOperand), pattern_of(rightOperand)), + std::type_identity {}); + if (leftOperand.is_negative() != rightOperand.is_negative() && subtracted.is_negative() != leftOperand.is_negative()) + return std::nullopt; + FORMULA_CENSUS_NOTE(Intermediate, magnitude(subtracted)); + return subtracted; +} + +[[nodiscard]] constexpr std::optional mul_checked_or_none(Int128 leftOperand, Int128 rightOperand) noexcept +{ + std::optional const productMagnitude = u128_mul_checked(magnitude(leftOperand), magnitude(rightOperand)); + if (!productMagnitude) + return std::nullopt; + bool const negative = !productMagnitude->is_zero() && leftOperand.is_negative() != rightOperand.is_negative(); + UInt128 const largestMagnitude = negative ? UInt128 { std::uint64_t { 1 } << 63, 0 } + : UInt128 { ~(std::uint64_t { 1 } << 63), ~std::uint64_t { 0 } }; + if (largestMagnitude < *productMagnitude) + return std::nullopt; + FORMULA_CENSUS_NOTE(Intermediate, *productMagnitude); + return signed_from_magnitude(*productMagnitude, negative); +} + +/// The greatest common divisor of two 128-bit magnitudes (`u128_gcd`). +[[nodiscard]] constexpr UInt128 gcd(UInt128 leftOperand, UInt128 rightOperand) noexcept +{ + return u128_gcd(leftOperand, rightOperand); +} + +/// `floor_divmod`'s answer on 128 bits. +struct WideDivMod +{ + /// The floored quotient. + Int128 quotient {}; + /// The remainder, in `[0, divisor)`. + Int128 remainder {}; +}; + +/// Floored division on 128 bits, as `floor_divmod` on 64. @pre `divisor > 0`. +[[nodiscard]] constexpr WideDivMod floor_divmod(Int128 dividend, Int128 divisor) noexcept +{ + Int128 truncated = dividend / divisor; + Int128 remainderLeft = dividend % divisor; + if (remainderLeft < 0) + { + --truncated; + remainderLeft += divisor; + } + return { truncated, remainderLeft }; +} + +/// Number of decimal digits in `|operandValue|`: at most 39. Zero has one. +[[nodiscard]] constexpr int decimal_digits(Int128 operandValue) noexcept +{ + 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. +[[nodiscard]] constexpr std::optional mul_pow10(Int128 operandValue, int exponent) noexcept +{ + std::optional const powerOfTen = pow10(exponent); + if (!powerOfTen) + return std::nullopt; + return mul_checked_or_none(operandValue, Int128 { *powerOfTen }); +} + } // namespace formula::detail diff --git a/include/formula-cpp/detail/least_squares_kernel.hpp b/include/formula-cpp/detail/least_squares_kernel.hpp index 0d9a7aef..d62d68f3 100644 --- a/include/formula-cpp/detail/least_squares_kernel.hpp +++ b/include/formula-cpp/detail/least_squares_kernel.hpp @@ -128,17 +128,18 @@ template /// `divmod_small`, one step per limb, rather than by the lcm's gcd and long /// division. template + requires(L >= 4) [[nodiscard]] constexpr std::optional> common_denominator(std::span observedColumn) noexcept { WideUnsigned common = WideUnsigned::from_u64(1); for (Rational const& observed: observedColumn) { - auto const denominatorValue = static_cast(observed.denominator()); - if (denominatorValue <= 0xFFFF'FFFFU - && divmod_small(common, static_cast(denominatorValue)).remainder == 0) + UInt128 const denominatorValue = wide_magnitude(observed.denominator()); + if (denominatorValue.fits_u64() && denominatorValue.lowWord <= 0xFFFF'FFFFU + && divmod_small(common, static_cast(denominatorValue.lowWord)).remainder == 0) continue; std::optional> const grown = - lcm_checked_or_none(common, WideUnsigned::from_u64(denominatorValue)); + lcm_checked_or_none(common, WideUnsigned::from_u128(denominatorValue)); if (!grown.has_value()) return std::nullopt; common = *grown; diff --git a/include/formula-cpp/detail/transcendental.hpp b/include/formula-cpp/detail/transcendental.hpp index 6de0a2f4..94333602 100644 --- a/include/formula-cpp/detail/transcendental.hpp +++ b/include/formula-cpp/detail/transcendental.hpp @@ -217,8 +217,14 @@ struct LogarithmMagnitude /// The enclosure of |ln(@p positive)| -- see the file comment. @pre @p positive > 0 and != 1. [[nodiscard]] constexpr std::optional natural_log_magnitude(Rational positive) noexcept { - auto larger = static_cast(positive.numerator()); - auto smaller = static_cast(positive.denominator()); + std::optional const numeratorWord = narrow_to_int64(positive.numerator()); + std::optional const denominatorWord = narrow_to_int64(positive.denominator()); + // The kernel works on a fraction of two values below 2^63; a wider one is + // beyond it. + if (!numeratorWord || !denominatorWord) + return std::nullopt; + auto larger = static_cast(*numeratorWord); + auto smaller = static_cast(*denominatorWord); LogarithmSign const logarithmSign = larger < smaller ? LogarithmSign::Negative : LogarithmSign::Positive; if (logarithmSign == LogarithmSign::Negative) std::swap(larger, smaller); @@ -288,8 +294,14 @@ struct LogarithmMagnitude [[nodiscard]] constexpr std::optional exponential_enclosure(Rational argument) noexcept { bool const negative = argument.sign() < 0; + std::optional const numeratorWord = narrow_to_int64(argument.numerator()); + std::optional const denominatorWord = narrow_to_int64(argument.denominator()); + // The kernel works on a fraction of two values below 2^63; a wider one is + // beyond it. + if (!numeratorWord || !denominatorWord) + return std::nullopt; ScaledQuotient const fixedMagnitude = - scaled_quotient(magnitude(argument.numerator()), static_cast(argument.denominator())); + scaled_quotient(magnitude(*numeratorWord), static_cast(*denominatorWord)); std::optional remainderBelow; std::uint32_t shifts = 0; if (!negative) diff --git a/include/formula-cpp/detail/wide_int.hpp b/include/formula-cpp/detail/wide_int.hpp index 58a1b72d..0422eac1 100644 --- a/include/formula-cpp/detail/wide_int.hpp +++ b/include/formula-cpp/detail/wide_int.hpp @@ -4,12 +4,12 @@ /// @file /// Fixed-width unsigned integers wider than 64 bits, for the exact arithmetic /// behind a declared precision (`rounded_output`, `opaque.hpp`): a value the -/// 64-bit `Rational` cannot hold is computed here exactly, and only its +/// 128-bit `Rational` cannot hold is computed here exactly, and only its /// rounding is ever written. /// /// `WideUnsigned` holds `Limbs` limbs of 32 bits, least significant /// first. Every product of two limbs is formed in `std::uint64_t`: cl has no -/// 128-bit integer (`rounded_root.hpp` gives the same reason), and nothing here +/// 128-bit integer (`int128.hpp` gives the same reason), and nothing here /// uses an intrinsic or floating point, so a result depends on its operands /// alone. /// @@ -28,6 +28,8 @@ /// A signed integer is a sign beside a magnitude (`WideSigned`), and a fraction /// a sign beside two magnitudes (`WideRatio`). Zero is never negative. +#include + #include #include #include @@ -61,6 +63,18 @@ class WideUnsigned return from_limbs(held); } + /// @p narrow, exactly. Four limbs hold it. + [[nodiscard]] static constexpr WideUnsigned from_u128(UInt128 narrow) noexcept + requires(Limbs >= 4) + { + std::array held {}; + held[0] = static_cast(narrow.lowWord & 0xFFFF'FFFFU); + held[1] = static_cast(narrow.lowWord >> 32U); + held[2] = static_cast(narrow.highWord & 0xFFFF'FFFFU); + held[3] = static_cast(narrow.highWord >> 32U); + return from_limbs(held); + } + /// The integer whose limbs are @p held, least significant first. [[nodiscard]] static constexpr WideUnsigned from_limbs(std::array const& held) noexcept { @@ -84,6 +98,27 @@ class WideUnsigned return (static_cast(_limbs[1]) << 32U) | _limbs[0]; } + /// This value as 128 bits, or nothing when it needs more. + [[nodiscard]] constexpr std::optional to_u128() const noexcept + { + // Every limb is read before anything is decided, with no early return + // in between. An early return left g++ 14 at -O3 a tail that reads only + // the low four limbs, which it split out of each width and then merged + // across widths; the merged copy, typed for a wider one, made + // -Warray-bounds report a read past a narrower value that never + // happens. + std::uint32_t aboveLow = 0; + for (std::size_t limbAt = 4; limbAt < Limbs; ++limbAt) + aboveLow |= _limbs[limbAt]; + // The low four limbs, zero past the top of a narrower value. + std::array lowLimbs {}; + for (std::size_t limbAt = 0; limbAt < lowLimbs.size() && limbAt < Limbs; ++limbAt) + lowLimbs[limbAt] = _limbs[limbAt]; + if (aboveLow != 0U) + return std::nullopt; + return UInt128 { (lowLimbs[3] << 32U) | lowLimbs[2], (lowLimbs[1] << 32U) | lowLimbs[0] }; + } + /// Whether this is zero. [[nodiscard]] constexpr bool is_zero() const noexcept { diff --git a/include/formula-cpp/detail/wide_rounding.hpp b/include/formula-cpp/detail/wide_rounding.hpp index 65008993..9ed8432c 100644 --- a/include/formula-cpp/detail/wide_rounding.hpp +++ b/include/formula-cpp/detail/wide_rounding.hpp @@ -10,7 +10,7 @@ /// `checked_round` accepts, it gives the same result in all seven modes at every /// place from -18 to 18 (`test/wide_rounding_tests.cpp` checks 43729 such /// cases). It also answers some values `checked_round` refuses -- where a -/// numerator times 10^places leaves 64 bits but the rounded result fits -- and +/// numerator times 10^places leaves 128 bits but the rounded result fits -- and /// refuses, with `Overflow`, a result that does not fit `Rational`, as /// `checked_round` does. /// @@ -19,7 +19,7 @@ /// it was reached. /// /// Integer arithmetic only (`detail/wide_int.hpp`): no floating point, no -/// intrinsic, no 128-bit integer type. +/// intrinsic, no compiler 128-bit integer (`__int128`). #include #include @@ -51,27 +51,28 @@ template divmod(unreduced.denominator, common).quotient }; } -/// @p exact as a wide fraction. Four limbs at least, so that the product of -/// its numerator and a 64-bit factor always fits. +/// @p exact as a wide fraction. Four limbs at least, so that a 128-bit +/// numerator fits. template requires(L >= 4) [[nodiscard]] constexpr WideRatio wide_from_rational(Rational exact) noexcept { return WideRatio { exact.numerator() < 0, - WideUnsigned::from_u64(magnitude(exact.numerator())), - WideUnsigned::from_u64(static_cast(exact.denominator())) }; + WideUnsigned::from_u128(wide_magnitude(exact.numerator())), + WideUnsigned::from_u128(wide_magnitude(exact.denominator())) }; } /// @p exact times @p commonDenominator, an integer; nothing when it does not /// fit. @pre `exact.denominator()` divides @p commonDenominator. template + requires(L >= 4) [[nodiscard]] constexpr std::optional> scaled_to_denominator(Rational exact, WideUnsigned const& commonDenominator) noexcept { WideUnsigned const cofactor = - divmod(commonDenominator, WideUnsigned::from_u64(static_cast(exact.denominator()))).quotient; + divmod(commonDenominator, WideUnsigned::from_u128(wide_magnitude(exact.denominator()))).quotient; std::optional> const scaledMagnitude = - mul_checked_or_none(WideUnsigned::from_u64(magnitude(exact.numerator())), cofactor); + mul_checked_or_none(WideUnsigned::from_u128(wide_magnitude(exact.numerator())), cofactor); if (!scaledMagnitude) return std::nullopt; return WideSigned { exact.numerator() < 0, *scaledMagnitude }; @@ -145,14 +146,12 @@ template } std::optional> const kept = awayFromZero ? add_small_checked_or_none(split.quotient, 1U) : split.quotient; - std::optional const keptMagnitude = kept ? kept->to_u64() : std::nullopt; - constexpr std::uint64_t positiveLimit = static_cast(IntMax); - if (!keptMagnitude || *keptMagnitude > (inLowestTerms.negative ? positiveLimit + 1U : positiveLimit)) + std::optional const keptMagnitude = kept ? kept->to_u128() : std::nullopt; + std::optional const mantissa = + keptMagnitude ? rational_int_from_magnitude(*keptMagnitude, inLowestTerms.negative) : std::nullopt; + if (!mantissa) return std::unexpected { ArithmeticError::Overflow }; - // Well defined since C++20: conversion to a signed type is modular. - auto const mantissa = inLowestTerms.negative ? static_cast(0U - *keptMagnitude) - : static_cast(*keptMagnitude); - return Rational::from_decimal(mantissa, -places.value); + return Rational::from_decimal(*mantissa, -places.value); } /// @p unreduced as the `Rational` of the same value: reduced first, then @@ -164,15 +163,15 @@ template if (unreduced.denominator.is_zero()) return std::unexpected { ArithmeticError::DivisionByZero }; WideRatio const inLowestTerms = reduced(unreduced); - std::optional const numeratorMagnitude = inLowestTerms.numerator.to_u64(); - std::optional const denominatorMagnitude = inLowestTerms.denominator.to_u64(); - constexpr std::uint64_t positiveLimit = static_cast(IntMax); - if (!numeratorMagnitude || !denominatorMagnitude || *denominatorMagnitude > positiveLimit - || *numeratorMagnitude > (inLowestTerms.negative ? positiveLimit + 1U : positiveLimit)) + std::optional const numeratorMagnitude = inLowestTerms.numerator.to_u128(); + std::optional const denominatorMagnitude = inLowestTerms.denominator.to_u128(); + std::optional const signedNumerator = + numeratorMagnitude ? rational_int_from_magnitude(*numeratorMagnitude, inLowestTerms.negative) : std::nullopt; + std::optional const positiveDenominator = + denominatorMagnitude ? rational_int_from_magnitude(*denominatorMagnitude, false) : std::nullopt; + if (!signedNumerator || !positiveDenominator) return std::unexpected { ArithmeticError::Overflow }; - auto const signedNumerator = inLowestTerms.negative ? static_cast(0U - *numeratorMagnitude) - : static_cast(*numeratorMagnitude); - return Rational::make(signedNumerator, static_cast(*denominatorMagnitude)); + return Rational::make(*signedNumerator, *positiveDenominator); } /// @p coherentValue, a value in the coherent unit of @p roundedIn's diff --git a/include/formula-cpp/environment.hpp b/include/formula-cpp/environment.hpp index bfc18f19..70739d41 100644 --- a/include/formula-cpp/environment.hpp +++ b/include/formula-cpp/environment.hpp @@ -179,9 +179,9 @@ namespace detail { /// Fails to compile when an element of `measured_series` is neither a /// `Measured`, `not_measured`, nor something `Rational` is built from - /// (a `Measured` of another quantity, a string, ...). A `double` or a wide - /// unsigned integer is refused by `Rational` itself, in its own words, and - /// draws nothing here. + /// (a `Measured` of another quantity, a string, ...). A `double` is refused + /// by `Rational` itself, in its own words. A built-in integer up to 64 bits + /// converts exactly; `bool`, and an integer wider than that, draw this one. template struct RequireSeriesElementOf { diff --git a/include/formula-cpp/format.hpp b/include/formula-cpp/format.hpp index 7335dbd5..929a7bcb 100644 --- a/include/formula-cpp/format.hpp +++ b/include/formula-cpp/format.hpp @@ -2,10 +2,11 @@ #pragma once /// @file -/// `std::format` for a `Rational`, a `Measured`, an `Outcome`, a `Unit`, -/// a `Dimension` and every enumeration that has a `describe()`: -/// `std::format("{}", Rational { 3, 5 })` is `0.6`, a measured 5.2 in a unit -/// whose symbol is `kJ` formats as `5.2 kJ`, `dim::Density` as `L^-3 M^1`, and +/// `std::format` for a `Rational`, an `Int128`, a `Measured`, an +/// `Outcome`, a `Unit`, a `Dimension` and every enumeration that has a +/// `describe()`: `std::format("{}", Rational { 3, 5 })` is `0.6`, an `Int128` +/// writes its decimal digits, a measured 5.2 in a unit whose symbol is `kJ` +/// formats as `5.2 kJ`, `dim::Density` as `L^-3 M^1`, and /// `ArithmeticError::Overflow` as `overflow in exact arithmetic`. /// /// **Opt-in.** This header is not included by `formula.hpp`: it includes @@ -43,16 +44,17 @@ /// /// **The library owns these specialisations of `std::formatter`.** A consumer /// who specialises `std::formatter` for `formula::Rational`, -/// `formula::Measured`, `formula::Outcome`, `formula::Unit` or -/// `formula::Dimension` as well defines one entity twice, which breaks the -/// one-definition rule. A consumer's own `std::formatter` for an -/// enumeration listed in `detail::formats_by_describe` does the same, and a -/// generic one constrained on `std::is_enum_v` is ambiguous for those -/// enumerations. Only `char` formatting is provided: a unit's symbol is UTF-8 -/// bytes. +/// `formula::Int128`, `formula::Measured`, `formula::Outcome`, +/// `formula::Unit` or `formula::Dimension` as well defines one entity twice, +/// which breaks the one-definition rule. A consumer's own +/// `std::formatter` for an enumeration listed in +/// `detail::formats_by_describe` does the same, and a generic one constrained +/// on `std::is_enum_v` is ambiguous for those enumerations. Only `char` +/// formatting is provided: a unit's symbol is UTF-8 bytes. #include #include +#include #include #include #include @@ -61,6 +63,7 @@ #include #include +#include #include #include #include @@ -509,6 +512,18 @@ inline void append_exponent_text(std::string& spelled, std::string_view baseName spelled = "(dimensionless)"; return spelled; } + +/// Refuses any format spec for a `formula::Int128` but the empty one: an +/// `Int128` is formatted only with `{}`, which writes its decimal digits. Not +/// `constexpr`, for the reason `formula_number_format_needs_a_rounding_mode` +/// gives. +/// @throws std::format_error always. +[[noreturn]] inline void formula_int128_format_spec_not_understood() +{ + throw std::format_error( + "formula: an Int128 is formatted only with {}, which writes its decimal digits -- it takes no fill, " + "alignment, width, precision or type"); +} } // namespace formula::detail // The specialisations are declared inside `namespace std` rather than as @@ -602,6 +617,38 @@ struct formatter formula::detail::NumberFormatSpec _spec {}; }; +/// `std::format` of a `formula::Int128`: its decimal digits, with a `-` when +/// it is negative, as `{}` writes a built-in integer. The empty spec is the +/// only one; any other calls `formula_int128_format_spec_not_understood`, a +/// compile error in a literal format string and `std::format_error` under +/// `std::vformat`. +/// +/// Owned by this library: a consumer's own specialisation of it would define +/// it twice, which breaks the one-definition rule. +template <> +struct formatter +{ + /// Accepts only the empty spec. + constexpr auto parse(std::format_parse_context& parseContext) + { + auto const specAt = parseContext.begin(); + if (specAt != parseContext.end() && *specAt != '}') + formula::detail::formula_int128_format_spec_not_understood(); + return specAt; + } + + /// Writes @p shown's digits. + template + auto format(formula::Int128 const& shown, FormatContext& formatContext) const + { + formula::detail::DecimalSpelling const spelled = formula::detail::u128_decimal(formula::detail::magnitude(shown)); + auto writtenTo = formatContext.out(); + if (shown.is_negative()) + *writtenTo++ = '-'; + return std::copy_n(spelled.characters, spelled.length, writtenTo); + } +}; + /// `std::format` of a `formula::Measured`: the number in `Q`'s declared /// unit, in the spellings `number_text` gives, then a space and the unit's /// symbol when it has one -- or `(not measured)` when the value is absent, diff --git a/include/formula-cpp/function.hpp b/include/formula-cpp/function.hpp index 6337ee01..bbb185ea 100644 --- a/include/formula-cpp/function.hpp +++ b/include/formula-cpp/function.hpp @@ -144,7 +144,7 @@ namespace detail } /// k when @p positive is exactly 10^k, and nothing otherwise. Read off the reduced fraction: a power - /// of ten is a power of ten over 1, or 1 over a power of ten, so k runs from -18 to 18, the powers of + /// of ten is a power of ten over 1, or 1 over a power of ten, so k runs from -38 to 38, the powers of /// ten `Rational::Int` holds. @pre @p positive is above zero. [[nodiscard]] constexpr std::optional power_of_ten_exponent(Rational positive) noexcept { @@ -297,7 +297,7 @@ struct RepFunctions return std::unexpected { ArithmeticError::Inexact }; } - /// The decimal logarithm of `argument`, exactly: k at 10^k, for k from -18 to 18. Every other positive + /// The decimal logarithm of `argument`, exactly: k at 10^k, for k from -38 to 38. Every other positive /// rational has an irrational one, which is `Inexact`; zero and below have none, which is /// `DomainError`. [[nodiscard]] static constexpr std::expected decimal_log(Rational argument) noexcept diff --git a/include/formula-cpp/int128.hpp b/include/formula-cpp/int128.hpp new file mode 100644 index 00000000..b2f95451 --- /dev/null +++ b/include/formula-cpp/int128.hpp @@ -0,0 +1,680 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// `formula::Int128`, the signed 128-bit integer `Rational` stores its +/// numerator and denominator in. +/// +/// One class, with one API on every compiler. It is held as two 64-bit words +/// in two's complement everywhere. Where the compiler has a 128-bit integer +/// -- GCC, Clang and AppleClang -- multiplication, division and the overflow +/// check are carried out in it; elsewhere, in portable `constexpr` code on the +/// two words. That is cl, and clang-cl too: it accepts `__int128`, but +/// dividing one calls compiler-rt's `__divti3`, which the MSVC linker does +/// not supply. Both routes give the same bits for every operation, at compile +/// time and at run time, so a number is the same on every compiler. +/// +/// Division, the remainder and the greatest common divisor take a 64-bit +/// route whenever their operands fit 64 bits, which nearly every number a +/// formula forms does. +/// +/// **No conversion to a built-in integer type**, implicit or explicit. +/// Narrowing is spelled `to_int64()` and `to_uint64()`, which answer +/// `std::nullopt` when the value does not fit, so that no code can cut a +/// 128-bit value down to 64 bits without saying what happens when it does +/// not fit. +/// +/// **Overflow is a precondition violation**, as for a built-in signed +/// integer. An arithmetic operator -- `+ - * / %` or unary `-` -- whose +/// exact result does not fit has broken its precondition: a sum, difference +/// or product past the range, -2^127 / -1, and -(-2^127). So has division +/// or remainder by zero. The shifts are outside it: `<<` loses the bits +/// shifted out, as a built-in `<<` does since C++20. A caller that needs to +/// know whether a result fits uses the checked forms in +/// `detail/checked_int.hpp`, as `Rational` does. `%` by -1 is 0 for every +/// dividend, the minimum included, since that result fits. +/// +/// Its `std::formatter` lives in `format.hpp`, with the library's others. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__SIZEOF_INT128__) && !defined(_MSC_VER) + /// 1 where the compiler's own 128-bit integer carries `Int128`'s + /// multiplication and division, 0 where the portable code does. Internal: + /// not part of the library's contract. + #define FORMULA_NATIVE_INT128 1 +#else + /// 0: the portable code carries `Int128`'s multiplication and division. + #define FORMULA_NATIVE_INT128 0 +#endif + +namespace formula::detail +{ + +/// An unsigned 128-bit integer, as two 64-bit words, the more significant +/// first so that the defaulted comparisons order it numerically. It holds +/// magnitudes: 2^127, the magnitude of `Int128`'s minimum, has no signed +/// counterpart. +struct UInt128 +{ + /// Bits 64 to 127. + std::uint64_t highWord = 0; + /// Bits 0 to 63. + std::uint64_t lowWord = 0; + + /// @p whole, widened. + [[nodiscard]] static constexpr UInt128 from_u64(std::uint64_t whole) noexcept { return UInt128 { 0, whole }; } + + /// Whether it fits 64 bits. + [[nodiscard]] constexpr bool fits_u64() const noexcept { return highWord == 0; } + + /// Whether it is zero. + [[nodiscard]] constexpr bool is_zero() const noexcept { return highWord == 0 && lowWord == 0; } + + /// How many bits writing it takes: 0 for zero, 128 at most. + [[nodiscard]] constexpr int bit_width() const noexcept + { + return highWord != 0 ? 64 + static_cast(std::bit_width(highWord)) : static_cast(std::bit_width(lowWord)); + } + + /// Numeric equality. + [[nodiscard]] constexpr bool operator==(UInt128 const&) const noexcept = default; + /// Numeric order: the more significant word first. + [[nodiscard]] constexpr std::strong_ordering operator<=>(UInt128 const&) const noexcept = default; +}; + +/// A quotient and a remainder. +struct UInt128Division +{ + /// The quotient, rounded toward zero. + UInt128 quotient {}; + /// What is left over: below the divisor. + UInt128 remainder {}; +}; + +/// The portable arithmetic on two words. Every compiler builds it and the +/// tests check it; `Int128` uses it where the compiler has no 128-bit +/// integer of its own. +namespace portable +{ + /// The sum, wrapping past 2^128. + [[nodiscard]] constexpr UInt128 add(UInt128 leftOperand, UInt128 rightOperand) noexcept + { + std::uint64_t const lowSum = leftOperand.lowWord + rightOperand.lowWord; + std::uint64_t const carried = lowSum < leftOperand.lowWord ? 1U : 0U; + return UInt128 { leftOperand.highWord + rightOperand.highWord + carried, lowSum }; + } + + /// The difference, wrapping below zero. + [[nodiscard]] constexpr UInt128 subtract(UInt128 leftOperand, UInt128 rightOperand) noexcept + { + std::uint64_t const borrowed = leftOperand.lowWord < rightOperand.lowWord ? 1U : 0U; + return UInt128 { leftOperand.highWord - rightOperand.highWord - borrowed, leftOperand.lowWord - rightOperand.lowWord }; + } + + /// The full product of two words, from their 32-bit halves. + [[nodiscard]] constexpr UInt128 multiply_words(std::uint64_t leftWord, std::uint64_t rightWord) noexcept + { + constexpr std::uint64_t HalfMask = 0xFFFF'FFFFU; + std::uint64_t const lowLow = (leftWord & HalfMask) * (rightWord & HalfMask); + std::uint64_t const highLow = (leftWord >> 32) * (rightWord & HalfMask); + std::uint64_t const lowHigh = (leftWord & HalfMask) * (rightWord >> 32); + std::uint64_t const highHigh = (leftWord >> 32) * (rightWord >> 32); + std::uint64_t const middle = (lowLow >> 32) + (highLow & HalfMask) + (lowHigh & HalfMask); + return UInt128 { highHigh + (highLow >> 32) + (lowHigh >> 32) + (middle >> 32), + (middle << 32) | (lowLow & HalfMask) }; + } + + /// The product's low 128 bits. + [[nodiscard]] constexpr UInt128 multiply(UInt128 leftOperand, UInt128 rightOperand) noexcept + { + UInt128 product = multiply_words(leftOperand.lowWord, rightOperand.lowWord); + // Each cross term lands in the high word; what passes 2^128 wraps away. + product.highWord += leftOperand.highWord * rightOperand.lowWord + leftOperand.lowWord * rightOperand.highWord; + return product; + } + + /// The product, or nothing when it needs more than 128 bits. + [[nodiscard]] constexpr std::optional multiply_checked(UInt128 leftOperand, UInt128 rightOperand) noexcept + { + if (leftOperand.highWord != 0 && rightOperand.highWord != 0) + return std::nullopt; + UInt128 product = multiply_words(leftOperand.lowWord, rightOperand.lowWord); + // At most one of the two cross terms is not zero. + UInt128 const crossTerm = leftOperand.highWord != 0 ? multiply_words(leftOperand.highWord, rightOperand.lowWord) + : multiply_words(leftOperand.lowWord, rightOperand.highWord); + if (crossTerm.highWord != 0) + return std::nullopt; + std::uint64_t const raisedHigh = product.highWord + crossTerm.lowWord; + if (raisedHigh < product.highWord) + return std::nullopt; + product.highWord = raisedHigh; + return product; + } + + /// Shifted left by @p places, below 128; the bits shifted out are lost. + [[nodiscard]] constexpr UInt128 shift_left(UInt128 operandValue, int places) noexcept + { + if (places == 0) + return operandValue; + if (places >= 64) + return UInt128 { operandValue.lowWord << (places - 64), 0 }; + return UInt128 { (operandValue.highWord << places) | (operandValue.lowWord >> (64 - places)), + operandValue.lowWord << places }; + } + + /// Shifted right by @p places, below 128, with zeros shifted in. + [[nodiscard]] constexpr UInt128 shift_right(UInt128 operandValue, int places) noexcept + { + if (places == 0) + return operandValue; + if (places >= 64) + return UInt128 { 0, operandValue.highWord >> (places - 64) }; + return UInt128 { operandValue.highWord >> places, + (operandValue.lowWord >> places) | (operandValue.highWord << (64 - places)) }; + } + + /// Long division, one bit of the quotient a step. @pre @p divisor is not zero. + [[nodiscard]] constexpr UInt128Division divide(UInt128 dividend, UInt128 divisor) noexcept + { + if (dividend < divisor) + return UInt128Division { UInt128 {}, dividend }; + int const shiftedBy = dividend.bit_width() - divisor.bit_width(); + UInt128 shiftedDivisor = shift_left(divisor, shiftedBy); + UInt128 quotientSoFar {}; + UInt128 remaining = dividend; + for (int place = shiftedBy; place >= 0; --place) + { + quotientSoFar = shift_left(quotientSoFar, 1); + if (!(remaining < shiftedDivisor)) + { + remaining = subtract(remaining, shiftedDivisor); + quotientSoFar.lowWord |= 1U; + } + shiftedDivisor = shift_right(shiftedDivisor, 1); + } + return UInt128Division { quotientSoFar, remaining }; + } +} // namespace portable + +#if FORMULA_NATIVE_INT128 +/// The compiler's own unsigned 128-bit integer. `__extension__` keeps +/// `-Wpedantic` quiet under `-std=c++23`; nothing outside this header and its +/// tests names it, and it is never handed to the standard library, whose +/// type traits do not count it as an integer in that mode. +__extension__ typedef unsigned __int128 NativeUInt128; + +/// @p operandValue as the compiler's own integer. +[[nodiscard]] constexpr NativeUInt128 to_native(UInt128 operandValue) noexcept +{ + return (static_cast(operandValue.highWord) << 64) | operandValue.lowWord; +} + +/// The compiler's own integer @p operandValue as two words. +[[nodiscard]] constexpr UInt128 from_native(NativeUInt128 operandValue) noexcept +{ + return UInt128 { static_cast(operandValue >> 64), static_cast(operandValue) }; +} +#endif + +/// The sum, wrapping past 2^128. +[[nodiscard]] constexpr UInt128 u128_add(UInt128 leftOperand, UInt128 rightOperand) noexcept +{ + return portable::add(leftOperand, rightOperand); +} + +/// The difference, wrapping below zero. +[[nodiscard]] constexpr UInt128 u128_sub(UInt128 leftOperand, UInt128 rightOperand) noexcept +{ + return portable::subtract(leftOperand, rightOperand); +} + +/// The full product of two words. +[[nodiscard]] constexpr UInt128 u128_mul_words(std::uint64_t leftWord, std::uint64_t rightWord) noexcept +{ +#if FORMULA_NATIVE_INT128 + return from_native(static_cast(leftWord) * rightWord); +#else + return portable::multiply_words(leftWord, rightWord); +#endif +} + +/// The product's low 128 bits. +[[nodiscard]] constexpr UInt128 u128_mul(UInt128 leftOperand, UInt128 rightOperand) noexcept +{ +#if FORMULA_NATIVE_INT128 + return from_native(to_native(leftOperand) * to_native(rightOperand)); +#else + return portable::multiply(leftOperand, rightOperand); +#endif +} + +/// The product, or nothing when it needs more than 128 bits. +[[nodiscard]] constexpr std::optional u128_mul_checked(UInt128 leftOperand, UInt128 rightOperand) noexcept +{ +#if FORMULA_NATIVE_INT128 + NativeUInt128 product = 0; + if (__builtin_mul_overflow(to_native(leftOperand), to_native(rightOperand), &product)) + return std::nullopt; + return from_native(product); +#else + return portable::multiply_checked(leftOperand, rightOperand); +#endif +} + +/// Quotient and remainder. @pre @p divisor is not zero. +[[nodiscard]] constexpr UInt128Division u128_divmod(UInt128 dividend, UInt128 divisor) noexcept +{ + if (dividend.fits_u64() && divisor.fits_u64()) + return UInt128Division { UInt128::from_u64(dividend.lowWord / divisor.lowWord), + UInt128::from_u64(dividend.lowWord % divisor.lowWord) }; +#if FORMULA_NATIVE_INT128 + NativeUInt128 const nativeDividend = to_native(dividend); + NativeUInt128 const nativeDivisor = to_native(divisor); + return UInt128Division { from_native(nativeDividend / nativeDivisor), from_native(nativeDividend % nativeDivisor) }; +#else + return portable::divide(dividend, divisor); +#endif +} + +/// How many zero bits end @p operandValue. @pre it is not zero. +[[nodiscard]] constexpr int u128_countr_zero(UInt128 operandValue) noexcept +{ + return operandValue.lowWord != 0 ? std::countr_zero(operandValue.lowWord) : 64 + std::countr_zero(operandValue.highWord); +} + +/// The greatest common divisor; that of 0 and n is n. Binary: no step +/// divides, where each of Euclid's would be a 128-bit division. Both +/// operands of 64 bits, at the start or on the way, finish in 64 bits. +[[nodiscard]] constexpr UInt128 u128_gcd(UInt128 leftOperand, UInt128 rightOperand) noexcept +{ + if (leftOperand.fits_u64() && rightOperand.fits_u64()) + return UInt128::from_u64(std::gcd(leftOperand.lowWord, rightOperand.lowWord)); + if (leftOperand.is_zero()) + return rightOperand; + if (rightOperand.is_zero()) + return leftOperand; + int const sharedTwos = u128_countr_zero( + UInt128 { leftOperand.highWord | rightOperand.highWord, leftOperand.lowWord | rightOperand.lowWord }); + leftOperand = portable::shift_right(leftOperand, u128_countr_zero(leftOperand)); + for (;;) + { + rightOperand = portable::shift_right(rightOperand, u128_countr_zero(rightOperand)); + if (rightOperand < leftOperand) + std::swap(leftOperand, rightOperand); + rightOperand = portable::subtract(rightOperand, leftOperand); + if (rightOperand.is_zero()) + break; + if (leftOperand.fits_u64() && rightOperand.fits_u64()) + { + leftOperand = UInt128::from_u64(std::gcd(leftOperand.lowWord, rightOperand.lowWord)); + break; + } + } + return portable::shift_left(leftOperand, sharedTwos); +} + +/// The largest r with r * r <= @p radicand, which is below 2^64 for every +/// radicand: one bit of the root a step, from the top. +[[nodiscard]] constexpr std::uint64_t u128_isqrt(UInt128 radicand) noexcept +{ + std::uint64_t rootSoFar = 0; + for (int bitAt = 63; bitAt >= 0; --bitAt) + { + std::uint64_t const candidate = rootSoFar | (std::uint64_t { 1 } << bitAt); + if (!(radicand < u128_mul_words(candidate, candidate))) + rootSoFar = candidate; + } + return rootSoFar; +} + +/// 10^@p exponent for 0 to 38, and nothing otherwise: 10^38 is the largest +/// power of ten below 2^127. +[[nodiscard]] constexpr std::optional u128_pow10(int exponent) noexcept +{ + if (exponent < 0 || exponent > 38) + return std::nullopt; + UInt128 power = UInt128::from_u64(1); + for (int multiplied = 0; multiplied < exponent; ++multiplied) + power = u128_mul(power, UInt128::from_u64(10)); + return power; +} + +/// The decimal digits of a 128-bit magnitude: at most 39. +struct DecimalSpelling +{ + /// The digits, most significant first; the first `length` are written. + char characters[39] {}; + /// How many digits there are: 1 for zero. + int length = 0; +}; + +/// @p magnitudeShown's decimal digits. +[[nodiscard]] constexpr DecimalSpelling u128_decimal(UInt128 magnitudeShown) noexcept +{ + char reversed[39] {}; + int produced = 0; + do + { + UInt128Division const split = u128_divmod(magnitudeShown, UInt128::from_u64(10)); + reversed[produced] = static_cast('0' + split.remainder.lowWord); + ++produced; + magnitudeShown = split.quotient; + } while (!magnitudeShown.is_zero()); + DecimalSpelling written {}; + written.length = produced; + for (int at = 0; at < produced; ++at) + written.characters[at] = reversed[produced - 1 - at]; + return written; +} + +/// The high word of @p whole's 128-bit two's complement form: all ones when +/// it is negative, zero otherwise. Outside `Int128`, so that it is defined +/// before any of that class's member bodies converts an `int` to it. +template +[[nodiscard]] constexpr std::uint64_t sign_extension_word(T whole) noexcept +{ + if constexpr (std::is_signed_v) + return whole < 0 ? ~std::uint64_t { 0 } : std::uint64_t { 0 }; + else + return 0; +} + +} // namespace formula::detail + +namespace formula +{ + +/// A signed 128-bit integer: `Rational`'s numerator and denominator. See +/// this header's file comment for how it computes, and why it converts to no +/// built-in integer type. +class Int128 +{ + public: + /// Zero. + constexpr Int128() noexcept = default; + + /// @p whole, exactly: every built-in integer of up to 64 bits fits. Not + /// `bool`, which is no number. + template + requires std::is_integral_v && (!std::is_same_v, bool>) && (sizeof(T) <= 8) + constexpr Int128(T whole) noexcept: + _highWord { detail::sign_extension_word(whole) }, + _lowWord { static_cast(whole) } + { + } + + /// The value whose two's complement words are @p highWord and @p lowWord. + [[nodiscard]] static constexpr Int128 from_words(std::uint64_t highWord, std::uint64_t lowWord) noexcept + { + Int128 made {}; + made._highWord = highWord; + made._lowWord = lowWord; + return made; + } + + /// Bits 64 to 127 of the two's complement form. + [[nodiscard]] constexpr std::uint64_t high_word() const noexcept { return _highWord; } + /// Bits 0 to 63 of the two's complement form. + [[nodiscard]] constexpr std::uint64_t low_word() const noexcept { return _lowWord; } + + /// Whether it is below zero. + [[nodiscard]] constexpr bool is_negative() const noexcept { return (_highWord >> 63) != 0U; } + + /// Whether `std::int64_t` holds it. + [[nodiscard]] constexpr bool fits_int64() const noexcept + { + return _highWord == ((_lowWord >> 63) != 0U ? ~std::uint64_t { 0 } : std::uint64_t { 0 }); + } + + /// It as a `std::int64_t`, or nothing when it does not fit. + [[nodiscard]] constexpr std::optional to_int64() const noexcept + { + if (!fits_int64()) + return std::nullopt; + // Well defined since C++20: conversion to a signed type is modular. + return static_cast(_lowWord); + } + + /// It as a `std::uint64_t`, or nothing when it is negative or does not fit. + [[nodiscard]] constexpr std::optional to_uint64() const noexcept + { + if (_highWord != 0U) + return std::nullopt; + return _lowWord; + } + + /// The nearest `double`, ties to even. Named, as `Rational::to_double` is, + /// so that every loss of exactness is visible where it happens. + [[nodiscard]] constexpr double to_double() const noexcept + { + if (fits_int64()) + return static_cast(static_cast(_lowWord)); + detail::UInt128 const magnitudeOf = magnitude_pattern(); + int const dropped = magnitudeOf.bit_width() - 64; + detail::UInt128 const kept = detail::portable::shift_right(magnitudeOf, dropped); + // A sticky bit below the 53 a double keeps: rounding to nearest then + // ties only when the bits dropped are exactly half a unit. + bool const inexact = !(detail::portable::shift_left(kept, dropped) == magnitudeOf); + double widened = static_cast(kept.lowWord | (inexact ? 1U : 0U)); + for (int doubled = 0; doubled < dropped; ++doubled) + widened *= 2.0; + return is_negative() ? -widened : widened; + } + + /// Numeric equality. + [[nodiscard]] constexpr bool operator==(Int128 const&) const noexcept = default; + + /// Numeric order. + [[nodiscard]] constexpr std::strong_ordering operator<=>(Int128 const& compared) const noexcept + { + if (_highWord != compared._highWord) + return static_cast(_highWord) <=> static_cast(compared._highWord); + return _lowWord <=> compared._lowWord; + } + + /// The sum. @pre it fits. + [[nodiscard]] friend constexpr Int128 operator+(Int128 leftOperand, Int128 rightOperand) noexcept + { + return from_pattern(detail::portable::add(leftOperand.as_pattern(), rightOperand.as_pattern())); + } + + /// The difference. @pre it fits. + [[nodiscard]] friend constexpr Int128 operator-(Int128 leftOperand, Int128 rightOperand) noexcept + { + return from_pattern(detail::portable::subtract(leftOperand.as_pattern(), rightOperand.as_pattern())); + } + + /// The product. @pre it fits. + [[nodiscard]] friend constexpr Int128 operator*(Int128 leftOperand, Int128 rightOperand) noexcept + { + return from_pattern(detail::u128_mul(leftOperand.as_pattern(), rightOperand.as_pattern())); + } + + /// The quotient, rounded toward zero. @pre @p divisor is not zero, and + /// the quotient fits: -2^127 / -1 does not. + [[nodiscard]] friend constexpr Int128 operator/(Int128 dividend, Int128 divisor) noexcept + { + detail::UInt128 const quotientMagnitude = + detail::u128_divmod(dividend.magnitude_pattern(), divisor.magnitude_pattern()).quotient; + // Negated through the bit pattern: a quotient of -2^127 fits, and its + // magnitude has no signed counterpart to apply unary minus to. + return from_pattern(dividend.is_negative() != divisor.is_negative() + ? detail::portable::subtract(detail::UInt128 {}, quotientMagnitude) + : quotientMagnitude); + } + + /// The remainder, of the dividend's sign; by -1 it is 0 for every + /// dividend, -2^127 included. @pre @p divisor is not zero. + [[nodiscard]] friend constexpr Int128 operator%(Int128 dividend, Int128 divisor) noexcept + { + Int128 const remainderMagnitude = + from_pattern(detail::u128_divmod(dividend.magnitude_pattern(), divisor.magnitude_pattern()).remainder); + return dividend.is_negative() ? -remainderMagnitude : remainderMagnitude; + } + + /// Shifted left by @p places, below 128. The bits shifted out are lost, + /// as with a built-in `<<` since C++20: `Int128 { 1 } << 127` is -2^127. + [[nodiscard]] friend constexpr Int128 operator<<(Int128 operandValue, int places) noexcept + { + return from_pattern(detail::portable::shift_left(operandValue.as_pattern(), places)); + } + + /// Shifted right by @p places, below 128: arithmetic, filling the bits + /// vacated with the sign, so -8 >> 1 is -4. + [[nodiscard]] friend constexpr Int128 operator>>(Int128 operandValue, int places) noexcept + { + detail::UInt128 const shifted = detail::portable::shift_right(operandValue.as_pattern(), places); + if (!operandValue.is_negative() || places == 0) + return from_pattern(shifted); + detail::UInt128 const signFill = + detail::portable::shift_left(detail::UInt128 { ~std::uint64_t { 0 }, ~std::uint64_t { 0 } }, 128 - places); + return from_pattern(detail::UInt128 { shifted.highWord | signFill.highWord, shifted.lowWord | signFill.lowWord }); + } + + /// The negation. @pre @p operandValue is not -2^127, whose negation does + /// not fit. + [[nodiscard]] friend constexpr Int128 operator-(Int128 operandValue) noexcept + { + return from_pattern(detail::portable::subtract(detail::UInt128 {}, operandValue.as_pattern())); + } + + /// Itself. + [[nodiscard]] friend constexpr Int128 operator+(Int128 operandValue) noexcept { return operandValue; } + + /// `*this = *this + rightOperand`. + constexpr Int128& operator+=(Int128 rightOperand) noexcept { return *this = *this + rightOperand; } + /// `*this = *this - rightOperand`. + constexpr Int128& operator-=(Int128 rightOperand) noexcept { return *this = *this - rightOperand; } + /// `*this = *this * rightOperand`. + constexpr Int128& operator*=(Int128 rightOperand) noexcept { return *this = *this * rightOperand; } + /// `*this = *this / rightOperand`. + constexpr Int128& operator/=(Int128 rightOperand) noexcept { return *this = *this / rightOperand; } + /// `*this = *this % rightOperand`. + constexpr Int128& operator%=(Int128 rightOperand) noexcept { return *this = *this % rightOperand; } + /// `*this = *this << places`. + constexpr Int128& operator<<=(int places) noexcept { return *this = *this << places; } + /// `*this = *this >> places`. + constexpr Int128& operator>>=(int places) noexcept { return *this = *this >> places; } + /// Adds one. + constexpr Int128& operator++() noexcept { return *this += 1; } + /// Subtracts one. + constexpr Int128& operator--() noexcept { return *this -= 1; } + /// Adds one, answering the value before. + constexpr Int128 operator++(int) noexcept + { + Int128 const before = *this; + ++*this; + return before; + } + /// Subtracts one, answering the value before. + constexpr Int128 operator--(int) noexcept + { + Int128 const before = *this; + --*this; + return before; + } + + private: + [[nodiscard]] constexpr detail::UInt128 as_pattern() const noexcept { return detail::UInt128 { _highWord, _lowWord }; } + + [[nodiscard]] static constexpr Int128 from_pattern(detail::UInt128 bitPattern) noexcept + { + return from_words(bitPattern.highWord, bitPattern.lowWord); + } + + [[nodiscard]] constexpr detail::UInt128 magnitude_pattern() const noexcept + { + return is_negative() ? detail::portable::subtract(detail::UInt128 {}, as_pattern()) : as_pattern(); + } + + std::uint64_t _highWord = 0; + std::uint64_t _lowWord = 0; +}; + +namespace detail +{ + /// The magnitude of @p operandValue: 2^127 for the minimum, which has no + /// signed counterpart. + [[nodiscard]] constexpr UInt128 magnitude(Int128 operandValue) noexcept + { + UInt128 const wordPattern { operandValue.high_word(), operandValue.low_word() }; + return operandValue.is_negative() ? portable::subtract(UInt128 {}, wordPattern) : wordPattern; + } + + /// The `Int128` of magnitude @p magnitudeOf, negative when @p negative. + /// @pre it fits: @p magnitudeOf is below 2^127, or is 2^127 and @p negative. + [[nodiscard]] constexpr Int128 signed_from_magnitude(UInt128 magnitudeOf, bool negative) noexcept + { + // Negated through the bit pattern: 2^127 has no signed counterpart to + // apply unary minus to. + UInt128 const signedPattern = negative ? portable::subtract(UInt128 {}, magnitudeOf) : magnitudeOf; + return Int128::from_words(signedPattern.highWord, signedPattern.lowWord); + } +} // namespace detail + +} // namespace formula + +namespace std +{ +/// `formula::Int128`'s limits, stated as a built-in signed integer's are. +template <> +class numeric_limits +{ + public: + static constexpr bool is_specialized = true; ///< Specialized here. + static constexpr bool is_signed = true; ///< Signed. + static constexpr bool is_integer = true; ///< An integer. + static constexpr bool is_exact = true; ///< Exact. + static constexpr bool has_infinity = false; ///< No infinity. + static constexpr bool has_quiet_NaN = false; ///< No quiet NaN. + static constexpr bool has_signaling_NaN = false; ///< No signaling NaN. + static constexpr std::float_round_style round_style = std::round_toward_zero; ///< Division truncates toward zero. + static constexpr bool is_iec559 = false; ///< Not a floating-point type. + static constexpr bool is_bounded = true; ///< Holds a finite range. + static constexpr bool is_modulo = false; ///< Overflow is a precondition violation, not a wrap. + static constexpr int digits = 127; ///< The bits besides the sign. + static constexpr int digits10 = 38; ///< Every 38-digit decimal fits. + static constexpr int max_digits10 = 0; ///< Zero: an integer has no rounding to undo. + static constexpr int radix = 2; ///< Binary. + static constexpr int min_exponent = 0; ///< Zero: an integer has no exponent. + static constexpr int min_exponent10 = 0; ///< Zero: an integer has no exponent. + static constexpr int max_exponent = 0; ///< Zero: an integer has no exponent. + static constexpr int max_exponent10 = 0; ///< Zero: an integer has no exponent. + static constexpr bool traps = false; ///< No arithmetic traps. + static constexpr bool tinyness_before = false; ///< False: an integer has no tininess. + + /// -2^127. + [[nodiscard]] static constexpr formula::Int128 min() noexcept + { + return formula::Int128::from_words(std::uint64_t { 1 } << 63, 0); + } + /// -2^127. + [[nodiscard]] static constexpr formula::Int128 lowest() noexcept { return min(); } + /// 2^127 - 1. + [[nodiscard]] static constexpr formula::Int128 max() noexcept + { + return formula::Int128::from_words(~(std::uint64_t { 1 } << 63), ~std::uint64_t { 0 }); + } + /// Zero: an integer has none. + [[nodiscard]] static constexpr formula::Int128 epsilon() noexcept { return 0; } + /// Zero: an integer has none. + [[nodiscard]] static constexpr formula::Int128 round_error() noexcept { return 0; } + /// Zero: an integer has none. + [[nodiscard]] static constexpr formula::Int128 infinity() noexcept { return 0; } + /// Zero: an integer has none. + [[nodiscard]] static constexpr formula::Int128 quiet_NaN() noexcept { return 0; } + /// Zero: an integer has none. + [[nodiscard]] static constexpr formula::Int128 signaling_NaN() noexcept { return 0; } + /// Zero: an integer has none. + [[nodiscard]] static constexpr formula::Int128 denorm_min() noexcept { return 0; } +}; +} // namespace std diff --git a/include/formula-cpp/least_squares.hpp b/include/formula-cpp/least_squares.hpp index a8e3dba7..f0d52400 100644 --- a/include/formula-cpp/least_squares.hpp +++ b/include/formula-cpp/least_squares.hpp @@ -18,23 +18,20 @@ /// /// **Exact in `Rational`**, and generic over `Rep` through `RepTraits`. It uses /// the centred sums, `S_xx = sum (x - mean x)^2` and `S_xy = sum (x - mean -/// x)(y - mean y)`: phase 15's spike (step 3) measured them overflowing at -/// the same first size as the uncentred sums on every data shape it tried, -/// and at fewer sizes. `docs/numeric-headroom.md` ("Least squares") carries -/// the census, regenerated with every build: for three-decimal readings near -/// 2410 N, 57 of the 127 sizes from 2 to 128 points overflow, the first at -/// 34. **Overflow depends on the data far more than on the number of -/// points**, and is not monotone in it: the same shape of readings passes at -/// some sizes above 34 and fails at others. An -/// intermediate beyond `Rational`'s range returns `Overflow`, never a wrong -/// number. +/// x)(y - mean y)`, rather than the uncentred sums of the coordinates' +/// squares and products. `docs/numeric-headroom.md` ("Least squares") carries +/// the census, regenerated with every build: three-decimal readings near +/// 2410 N fit at every size from 2 to 128 points, while a different +/// denominator on every point overflows from 28 points. **Overflow depends on +/// the data far more than on the number of points.** An intermediate beyond +/// `Rational`'s range returns `Overflow`, never a wrong number. /// /// **Rounded where it is used, it answers further.** /// `rounded_output<"slope", U, Places, Mode>(fit)` runs `compute_exact`, the /// same line from uncentred 256-bit integer sums, and reports the slope at the -/// precision the method declares: on those readings at every size from 2 to -/// 128 points. A different denominator on every point outgrows even that from -/// 58 points, and the answer is `Overflow`. +/// precision the method declares: on a different denominator on every point +/// up to 57 points. From 58 it outgrows even that, and the answer is +/// `Overflow`. /// /// **In `double`, the fit is the consumer's own route, outside the /// library.** A curve is evaluated only in `Rational` (`curve.hpp`), so a fit diff --git a/include/formula-cpp/lookup.hpp b/include/formula-cpp/lookup.hpp index d298c4c0..c85d778e 100644 --- a/include/formula-cpp/lookup.hpp +++ b/include/formula-cpp/lookup.hpp @@ -409,7 +409,7 @@ /// a row whose value is representable can never come back as an `Overflow` at /// all. It can. `checked_evaluate_si` still hands the answer to /// `detail::in_si`, which converts it out of `ResultUnit` into the coherent -/// unit, and **a unit conversion is arithmetic** -- a row stating `2^62` +/// unit, and **a unit conversion is arithmetic** -- a row stating `2^126` /// kilometres is a perfectly representable `Rational` that overflows on the way /// to metres. That path is shared with the banded and the exact lookup, which /// have it for exactly the same reason, and nothing about it is particular to @@ -481,6 +481,7 @@ #include #include #include +#include #include #include #include @@ -1327,11 +1328,31 @@ struct Breakpoint return { keyNumerator, keyDenominator }; } +namespace detail +{ + /// A `breakpoint` key that a `Breakpoint`'s `std::int64_t` numerator or + /// denominator cannot hold. Deliberately not `constexpr`: reaching it in a + /// constant expression fails to compile, naming it. At run time it ends + /// the program -- a `Breakpoint` is a template argument, built at compile + /// time, and has no way to carry a failure. + [[noreturn]] inline void formula_breakpoint_key_out_of_range() + { + std::abort(); + } +} // namespace detail + /// Builds a `Breakpoint` from its key as an exact number: `breakpoint(12.7_r)`. /// An integer still takes the overload above, so `breakpoint(127)` is unchanged. +/// A key beyond 64 bits fails to compile, naming +/// `formula_breakpoint_key_out_of_range`; reached at run time, that guard +/// ends the program. [[nodiscard]] constexpr Breakpoint breakpoint(Rational keyValue) noexcept { - return { keyValue.numerator(), keyValue.denominator() }; + std::optional const keyTop = detail::narrow_to_int64(keyValue.numerator()); + std::optional const keyBottom = detail::narrow_to_int64(keyValue.denominator()); + if (!keyTop || !keyBottom) + detail::formula_breakpoint_key_out_of_range(); + return { *keyTop, *keyBottom }; } namespace detail @@ -1582,12 +1603,15 @@ namespace detail /// far the computation gets before it has to report `Overflow`. /// /// **Neither order dominates**, and the comment that used to stand here - /// claimed one did. Measured, both directions: + /// claimed one did. Both directions: /// - /// - keys `{0, 10}` with values `{0, 2^62}`, asked at 5: dividing first - /// answers `2^61` exactly; multiplying first reports `Overflow`. - /// - keys `{0, 4e9}` with values `{0, 4e9}`, asked at `1/4e9`: multiplying - /// first answers `1/4e9` exactly; dividing first reports `Overflow`. + /// - keys `{0, 10}` with values `{0, 2^126}`, asked at 5: dividing first + /// answers `2^125` exactly; multiplying first would form `5 * 2^126` and + /// report `Overflow`. + /// - keys `{0, 4e9}` with values `{0, 4e9}`, asked at `2^-100`: multiplying + /// first would answer `2^-100` exactly; dividing first forms the weight + /// `2^-100 / 4e9`, which does not fit, and reports `Overflow`. Asked at + /// `1/4e9`, the same table answers `1/4e9` in the chosen order. /// /// Dividing first is chosen because it is the better order for the tables /// this library is actually for. A published curve states its rows on a diff --git a/include/formula-cpp/number_text.hpp b/include/formula-cpp/number_text.hpp index 5713497d..7cf84960 100644 --- a/include/formula-cpp/number_text.hpp +++ b/include/formula-cpp/number_text.hpp @@ -15,7 +15,7 @@ /// whose decimal expansion never ends is never passed off as one that does. /// /// A core header: it includes no ``, and the arithmetic is plain -/// `std::uint64_t` on the value's magnitude and denominator, never +/// 128-bit unsigned arithmetic on the value's magnitude and denominator, never /// `Rational::make`, so spelling a number reports nothing to the overflow /// census (`detail/checked_int.hpp`). The one exception is rounding to a /// negative number of places, which is `checked_round`'s own arithmetic -- @@ -37,10 +37,12 @@ namespace formula { /// Bytes of text a `NumberText` can hold. The longest text this header -/// spells is 59 bytes -- the marker, a sign, 19 whole digits, a point and 18 -/// places, then a space and a unit symbol of `SymbolCapacity` bytes -- and a -/// `static_assert` below keeps that true. -inline constexpr std::size_t NumberTextCapacity = 64; +/// 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 +/// 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. +inline constexpr std::size_t NumberTextCapacity = 128; /// The one spelling of "approximately": U+2248, `≈`, in UTF-8. Not `~`: a /// pair of those is GFM strikethrough, as `render.hpp`'s Markdown escaping @@ -218,22 +220,12 @@ namespace detail put(spelled, each); } - /// Appends @p wholeNumber in decimal: at most 20 digits. - static constexpr void put_whole(NumberText& spelled, std::uint64_t wholeNumber) noexcept + /// Appends @p wholeNumber in decimal: at most 39 digits. + static constexpr void put_whole(NumberText& spelled, UInt128 wholeNumber) noexcept { - char reversed[20] {}; - std::size_t produced = 0; - do - { - reversed[produced] = static_cast('0' + wholeNumber % 10U); - ++produced; - wholeNumber /= 10U; - } while (wholeNumber != 0U); - while (produced > 0) - { - --produced; - put(spelled, reversed[produced]); - } + DecimalSpelling const written = u128_decimal(wholeNumber); + for (int at = 0; at < written.length; ++at) + put(spelled, written.characters[at]); } /// Appends a point and the first @p shownPlaces of @p fractionDigits, @@ -251,16 +243,21 @@ namespace detail static constexpr void mark_rounded(NumberText& spelled) noexcept { spelled._exact = false; } }; - /// The longest text this header spells: the marker, a sign, the 19 digits - /// of 2^63, a point and 18 places, a space, and a unit symbol of - /// `SymbolCapacity` bytes -- `view(Symbol const&)` returns that many from a - /// symbol with no terminator. - inline constexpr std::size_t LongestNumberText = ApproximationMarker.size() + 1 + 19 + 1 + 18 + 1 + SymbolCapacity; + /// The longest text this header spells: a fraction of a sign, the 39 + /// digits of 2^127, a slash, a 39-digit denominator, a space, and a unit + /// symbol of `SymbolCapacity` bytes -- `view(Symbol const&)` returns that + /// many from a symbol with no terminator. A fraction is never marked + /// approximate; the longest marked decimal -- the marker, a sign, 38 + /// whole digits (a marked decimal's denominator is at least 3, and + /// 2^127 / 3 has 38), a point and 18 places, a space and the symbol -- is + /// shorter. + inline constexpr std::size_t LongestNumberText = 1 + 39 + 1 + 39 + 1 + SymbolCapacity; static_assert(LongestNumberText <= NumberTextCapacity, "formula: NumberTextCapacity is too small for the longest number this library spells"); - /// 10^18, the largest power of ten `Rational::Int` holds: the finest - /// scale this header writes a decimal on. + /// 10^18, the finest scale this header writes a decimal on: rounding's + /// places stop at 18, as `DecimalPlaces` does, though `Rational::Int` + /// holds powers of ten up to 10^38. inline constexpr std::uint64_t ExactDecimalScale = 1'000'000'000'000'000'000ULL; /// The places `ExactDecimalScale` spans -- also the largest @@ -277,12 +274,16 @@ namespace detail /// @pre `0 <= minimumPlaces <= 18`. [[nodiscard]] constexpr std::optional exact_decimal_digits(Rational shownValue, int minimumPlaces) noexcept { - auto const divisor = static_cast(shownValue.denominator()); - if (ExactDecimalScale % divisor != 0U) + UInt128 const divisor = wide_magnitude(shownValue.denominator()); + UInt128Division const scaleSplit = u128_divmod(UInt128::from_u64(ExactDecimalScale), divisor); + if (!scaleSplit.remainder.is_zero()) return std::nullopt; - std::uint64_t const magnitudeShown = magnitude(shownValue.numerator()); - std::uint64_t fractional = (magnitudeShown % divisor) * (ExactDecimalScale / divisor); + UInt128Division const shownSplit = u128_divmod(wide_magnitude(shownValue.numerator()), divisor); + // The remainder is below the divisor, which divides 10^18, so both it + // and the cofactor 10^18 / divisor fit 64 bits, and so does their + // product, which stays below 10^18. + std::uint64_t fractional = shownSplit.remainder.lowWord * scaleSplit.quotient.lowWord; char fractionDigits[ExactDecimalPlaces] {}; for (int place = ExactDecimalPlaces - 1; place >= 0; --place) { @@ -296,7 +297,7 @@ namespace detail NumberText spelled = NumberTextAccess::blank(); if (shownValue.sign() < 0) NumberTextAccess::put(spelled, '-'); - NumberTextAccess::put_whole(spelled, magnitudeShown / divisor); + NumberTextAccess::put_whole(spelled, shownSplit.quotient); NumberTextAccess::put_fraction(spelled, fractionDigits, shownPlaces); return spelled; } @@ -310,13 +311,13 @@ namespace detail /// a tie is found by comparing @p remainderLeft with /// `divisor - remainderLeft`, with no doubling. A tie under a mode that is /// none of the seven is refused with `DomainError`, as there. - [[nodiscard]] constexpr std::expected moves_away_from_zero(std::uint64_t remainderLeft, - std::uint64_t divisor, + [[nodiscard]] constexpr std::expected moves_away_from_zero(UInt128 remainderLeft, + UInt128 divisor, bool negative, bool lastKeptOdd, RoundingMode roundingMode) noexcept { - if (remainderLeft == 0U) + if (remainderLeft.is_zero()) return false; switch (roundingMode) @@ -333,7 +334,7 @@ namespace detail break; } - std::uint64_t const distanceUp = divisor - remainderLeft; + UInt128 const distanceUp = u128_sub(divisor, remainderLeft); if (remainderLeft < distanceUp) return false; if (remainderLeft > distanceUp) @@ -359,7 +360,9 @@ namespace detail /// and `1/3` do not. [[nodiscard]] constexpr bool has_exact_decimal(Rational shownValue) noexcept { - return detail::ExactDecimalScale % static_cast(shownValue.denominator()) == 0U; + return detail::u128_divmod(detail::UInt128::from_u64(detail::ExactDecimalScale), + detail::wide_magnitude(shownValue.denominator())) + .remainder.is_zero(); } /// @p shownValue as its exact decimal, with no trailing zeros: `0.6`, @@ -377,11 +380,11 @@ namespace detail NumberText spelled = detail::NumberTextAccess::blank(); if (shownValue.sign() < 0) detail::NumberTextAccess::put(spelled, '-'); - detail::NumberTextAccess::put_whole(spelled, detail::magnitude(shownValue.numerator())); + detail::NumberTextAccess::put_whole(spelled, detail::wide_magnitude(shownValue.numerator())); if (shownValue.denominator() != 1) { detail::NumberTextAccess::put(spelled, '/'); - detail::NumberTextAccess::put_whole(spelled, static_cast(shownValue.denominator())); + detail::NumberTextAccess::put_whole(spelled, detail::wide_magnitude(shownValue.denominator())); } return spelled; } @@ -393,11 +396,12 @@ namespace detail /// /// The text is exactly what `checked_round` would round to, but not by way /// of it: for 0 to 18 places the digits come from long division on the -/// magnitude and denominator, in `std::uint64_t`, so the text exists even -/// where `checked_round`'s own arithmetic overflows -- `IntMax/3` to 18 -/// places is `3074457345618258602.333333333333333333`, while `checked_round` -/// reports `Overflow` for it. Each digit is found without forming -/// `remainder * 10`: the remainder is added ten times, taking the +/// magnitude and denominator, in 128-bit unsigned integers, so the text +/// exists even where `checked_round`'s own arithmetic overflows -- the +/// largest `Rational::Int` over 3, to 18 places, is +/// `56713727820156410577229101238628035242.333333333333333333`, while +/// `checked_round` reports `Overflow` for it. Each digit is found without +/// forming `remainder * 10`: the remainder is added ten times, taking the /// denominator off whenever the sum reaches it, so the sum stays below twice /// the denominator. A value that rounds to zero is written without a `-`. /// @@ -433,10 +437,10 @@ namespace detail return *spelledRounded; } - auto const divisor = static_cast(unrounded.denominator()); - std::uint64_t const magnitudeShown = detail::magnitude(unrounded.numerator()); - std::uint64_t wholePart = magnitudeShown / divisor; - std::uint64_t remainderLeft = magnitudeShown % divisor; + detail::UInt128 const divisor = detail::wide_magnitude(unrounded.denominator()); + detail::UInt128Division const shownSplit = detail::u128_divmod(detail::wide_magnitude(unrounded.numerator()), divisor); + detail::UInt128 wholePart = shownSplit.quotient; + detail::UInt128 remainderLeft = shownSplit.remainder; char fractionDigits[detail::ExactDecimalPlaces] {}; for (int place = 0; place < places.value; ++place) @@ -444,15 +448,15 @@ namespace detail // The next digit is floor(remainderLeft * 10 / divisor), and the // next remainder remainderLeft * 10 mod divisor. Both are found by // adding remainderLeft ten times: before each addition the sum is - // below divisor, so after it the sum is below 2 * divisor < 2^64. - std::uint64_t tenfold = 0; + // below divisor, so after it the sum is below 2 * divisor < 2^128. + detail::UInt128 tenfold {}; int nextDigit = 0; for (int added = 0; added < 10; ++added) { - tenfold += remainderLeft; - if (tenfold >= divisor) + tenfold = detail::u128_add(tenfold, remainderLeft); + if (!(tenfold < divisor)) { - tenfold -= divisor; + tenfold = detail::u128_sub(tenfold, divisor); ++nextDigit; } } @@ -462,7 +466,7 @@ namespace detail bool const negative = unrounded.sign() < 0; bool const lastKeptOdd = - places.value > 0 ? (fractionDigits[places.value - 1] - '0') % 2 != 0 : wholePart % 2U != 0U; + places.value > 0 ? (fractionDigits[places.value - 1] - '0') % 2 != 0 : (wholePart.lowWord & 1U) != 0U; std::expected const awayFromZero = detail::moves_away_from_zero(remainderLeft, divisor, negative, lastKeptOdd, roundingMode); if (!awayFromZero) @@ -477,12 +481,18 @@ namespace detail --place; } if (place >= 0) + { fractionDigits[place] = static_cast(fractionDigits[place] + 1); + } else - ++wholePart; // at most 2^62 + 1: a value with a remainder has a denominator of at least 2 + { + // Below 2^127: a value with a remainder has a denominator of at + // least 2. + wholePart = detail::u128_add(wholePart, detail::UInt128::from_u64(1)); + } } - bool roundedToZero = wholePart == 0U; + bool roundedToZero = wholePart.is_zero(); for (int place = 0; place < places.value; ++place) roundedToZero = roundedToZero && fractionDigits[place] == '0'; @@ -495,7 +505,7 @@ namespace detail detail::NumberTextAccess::put(spelled, '-'); detail::NumberTextAccess::put_whole(spelled, wholePart); detail::NumberTextAccess::put_fraction(spelled, fractionDigits, shownPlaces); - if (remainderLeft != 0U) + if (!remainderLeft.is_zero()) detail::NumberTextAccess::mark_rounded(spelled); return spelled; } diff --git a/include/formula-cpp/precision.hpp b/include/formula-cpp/precision.hpp index 0163c2d2..5917df50 100644 --- a/include/formula-cpp/precision.hpp +++ b/include/formula-cpp/precision.hpp @@ -1010,7 +1010,8 @@ template ().value_or( detail::level_expression_unit().value_or(coherent(Level::dimension))); diff --git a/include/formula-cpp/rational.hpp b/include/formula-cpp/rational.hpp index 42eb8ce2..1b4f4e6f 100644 --- a/include/formula-cpp/rational.hpp +++ b/include/formula-cpp/rational.hpp @@ -2,7 +2,7 @@ #pragma once /// @file -/// An exact rational number over std::int64_t. +/// An exact rational number over `formula::Int128`. /// /// Norm rounding rules are specified behaviour, not presentation: "round the /// result to 0.1 %" is part of the method. Binary floating point cannot express @@ -15,6 +15,7 @@ #include #include +#include #include #include @@ -50,28 +51,27 @@ namespace detail class Rational { public: - /// The signed integer type numerator and denominator are stored in. - using Int = detail::Int; + /// The signed integer numerator and denominator are stored in: 128 bits + /// (`int128.hpp`). + using Int = Int128; /// Zero. constexpr Rational() noexcept = default; /// An integer is a rational, exactly and without narrowing. /// - /// Constrained to integer types that reach Int without loss. An unsigned type - /// as wide as Int is rejected at compile time: `std::size_t { 1 } << 63` would - /// otherwise convert by modular wraparound and become a *negative* Rational, - /// and `SIZE_MAX` would become -1. Producing a wrong number without saying so - /// is the one thing this library must never do. + /// Every built-in integer type of up to 64 bits fits `Int` exactly. template - requires std::is_integral_v && (!std::is_same_v, bool>) + requires std::is_integral_v && (!std::is_same_v, bool>) && (sizeof(T) <= 8) constexpr Rational(T whole) noexcept: - _numerator { static_cast(whole) } + _numerator { whole } + { + } + + /// An `Int`, exactly. + constexpr Rational(Int whole) noexcept: + _numerator { whole } { - static_assert(std::is_signed_v || sizeof(T) < sizeof(Int), - "formula: this unsigned type can hold values above Rational's maximum, where the " - "conversion would silently produce a negative value; check the range and use " - "Rational::make(value, 1), or cast explicitly"); } /// Deliberately unusable. A double is a binary fraction: `Rational r = 0.45;` @@ -103,24 +103,24 @@ class Rational if (dividend == 0) return Rational {}; - // Reduce in the unsigned domain so that IntMin is an ordinary operand. - std::uint64_t const numeratorMagnitude = detail::magnitude(dividend); - std::uint64_t const denominatorMagnitude = detail::magnitude(divisor); - std::uint64_t const common = detail::gcd(numeratorMagnitude, denominatorMagnitude); - std::uint64_t const reducedNumerator = numeratorMagnitude / common; - std::uint64_t const reducedDenominator = denominatorMagnitude / common; + // Reduce in the unsigned domain so that the minimum is an ordinary operand. + detail::UInt128 const numeratorMagnitude = detail::magnitude(dividend); + detail::UInt128 const denominatorMagnitude = detail::magnitude(divisor); + detail::UInt128 const common = detail::gcd(numeratorMagnitude, denominatorMagnitude); + detail::UInt128 const reducedNumerator = detail::u128_divmod(numeratorMagnitude, common).quotient; + detail::UInt128 const reducedDenominator = detail::u128_divmod(denominatorMagnitude, common).quotient; bool const negative = (dividend < 0) != (divisor < 0); - constexpr std::uint64_t PositiveLimit = static_cast(detail::IntMax); - std::uint64_t const numeratorLimit = negative ? PositiveLimit + 1U : PositiveLimit; - if (reducedDenominator > PositiveLimit || reducedNumerator > numeratorLimit) + constexpr detail::UInt128 PositiveLimit = detail::magnitude(std::numeric_limits::max()); + detail::UInt128 const numeratorLimit = + negative ? detail::u128_add(PositiveLimit, detail::UInt128::from_u64(1)) : PositiveLimit; + if (PositiveLimit < reducedDenominator || numeratorLimit < reducedNumerator) return std::unexpected { ArithmeticError::Overflow }; Rational made {}; - // Well defined since C++20: conversion to a signed type is modular. - made._numerator = negative ? static_cast(0U - reducedNumerator) : static_cast(reducedNumerator); - made._denominator = static_cast(reducedDenominator); + made._numerator = detail::signed_from_magnitude(reducedNumerator, negative); + made._denominator = detail::signed_from_magnitude(reducedDenominator, false); return made; } @@ -173,15 +173,13 @@ class Rational ++shifted; } - if (reduced > static_cast(detail::IntMax)) - return std::unexpected { ArithmeticError::Overflow }; - auto scaledReduced = static_cast(reduced); + Int scaledReduced { reduced }; if (negative) scaledReduced = -scaledReduced; if (shifted >= 0) { - if (shifted >= 63) + if (shifted >= 127) return std::unexpected { ArithmeticError::Overflow }; std::optional const scaledNumerator = detail::mul_checked_or_none(scaledReduced, Int { 1 } << shifted); if (!scaledNumerator) @@ -189,7 +187,7 @@ class Rational return Rational { *scaledNumerator }; } - if (-shifted >= 63) + if (-shifted >= 127) return std::unexpected { ArithmeticError::Overflow }; return make(scaledReduced, Int { 1 } << -shifted); } @@ -226,7 +224,7 @@ class Rational /// at the call site. [[nodiscard]] constexpr double to_double() const noexcept { - return static_cast(_numerator) / static_cast(_denominator); + return _numerator.to_double() / _denominator.to_double(); } /// Exact for every representable pair. Uses the continued-fraction @@ -283,6 +281,24 @@ class Rational Int _denominator { 1 }; }; +namespace detail +{ + /// The `Rational::Int` of magnitude @p magnitudeOf, negative when + /// @p negative, or nothing when `Rational::Int` cannot hold it: one + /// spelling for code that builds a numerator from a wide magnitude, + /// whatever `Rational::Int`'s width. + [[nodiscard]] constexpr std::optional rational_int_from_magnitude(UInt128 magnitudeOf, + bool negative) noexcept + { + UInt128 const largestPositive = wide_magnitude(std::numeric_limits::max()); + UInt128 const largestAllowed = negative ? u128_add(largestPositive, UInt128::from_u64(1)) : largestPositive; + if (largestAllowed < magnitudeOf) + return std::nullopt; + UInt128 const wordPattern = negative ? u128_sub(UInt128 {}, magnitudeOf) : magnitudeOf; + return int_from_pattern(wordPattern, std::type_identity {}); + } +} // namespace detail + /// Reciprocal. Fails on zero, and on the one value whose reciprocal is not /// representable. Checked-only: there is no natural infallible-looking /// spelling for this operation the way negation has unary `-`. @@ -297,7 +313,7 @@ class Rational /// representable. Its throwing counterpart is unary `operator-`. [[nodiscard]] constexpr std::expected checked_negate(Rational operandValue) noexcept { - if (operandValue.numerator() == detail::IntMin) + if (operandValue.numerator() == std::numeric_limits::min()) return std::unexpected { ArithmeticError::Overflow }; // Already canonical: negating the numerator preserves both invariants. return Rational::make(-operandValue.numerator(), operandValue.denominator()); @@ -311,18 +327,16 @@ class Rational /// /// Known limitation: the numerator sum itself is not protected, so this reports /// Overflow for a few operand pairs whose reduced result would fit -- both -/// numerators near 2^63 over denominators sharing a large factor. Measured: it -/// never occurs for numerators below roughly 10^6. The failure direction is -/// safe (a refusal, never a wrong number); lifting it needs 128-bit -/// intermediates, which MSVC cannot express portably. +/// numerators near 2^127 over denominators sharing a large factor. The failure +/// direction is safe: a refusal, never a wrong number. [[nodiscard]] constexpr std::expected checked_add(Rational leftOperand, Rational rightOperand) noexcept { // Scale by the least common multiple rather than by the product: with // denominators 6 and 10 this uses 30, not 60 -- the difference between // fitting and overflowing once denominators get large. - auto const common = static_cast(detail::gcd(static_cast(leftOperand.denominator()), - static_cast(rightOperand.denominator()))); + Rational::Int const common = detail::signed_from_magnitude( + detail::gcd(detail::magnitude(leftOperand.denominator()), detail::magnitude(rightOperand.denominator())), false); Rational::Int const leftScale = leftOperand.denominator() / common; Rational::Int const rightScale = rightOperand.denominator() / common; @@ -355,16 +369,16 @@ class Rational [[nodiscard]] constexpr std::expected checked_mul(Rational leftOperand, Rational rightOperand) noexcept { - // Cross-reduce before multiplying: (IntMax/3) * (3/IntMax) is exactly 1, but + // Cross-reduce before multiplying: (max/3) * (3/max) is exactly 1, but // multiplying the numerators first would overflow. // - // Each gcd divides a denominator, so it is positive and at most IntMax. - // Signed division by it is therefore always safe -- the only signed division - // that can overflow is IntMin / -1. - auto const leftCross = static_cast( - detail::gcd(detail::magnitude(leftOperand.numerator()), static_cast(rightOperand.denominator()))); - auto const rightCross = static_cast( - detail::gcd(detail::magnitude(rightOperand.numerator()), static_cast(leftOperand.denominator()))); + // Each gcd divides a denominator, so it is positive and at most `Int`'s + // maximum. Signed division by it is therefore always safe -- the only + // signed division that can overflow is the minimum / -1. + Rational::Int const leftCross = detail::signed_from_magnitude( + detail::gcd(detail::magnitude(leftOperand.numerator()), detail::magnitude(rightOperand.denominator())), false); + Rational::Int const rightCross = detail::signed_from_magnitude( + detail::gcd(detail::magnitude(rightOperand.numerator()), detail::magnitude(leftOperand.denominator())), false); Rational::Int const leftNumerator = leftOperand.numerator() / leftCross; Rational::Int const rightNumerator = rightOperand.numerator() / rightCross; @@ -682,35 +696,35 @@ namespace detail if (radicand.sign() < 0 && degree % 2 == 0) return std::unexpected { ArithmeticError::DomainError }; - // IntMin has no positive counterpart Int can hold -- its magnitude is - // IntMax + 1 -- so negating it to reach a positive intermediate is signed - // overflow, undefined behaviour. checked_negate refuses the same numerator - // for the same reason; this follows that precedent rather than inventing a - // second rule for it. Overflow is the honest answer here, not Inexact: - // Inexact means no exact root exists, but IntMin's cube root, -2^21, both - // exists and is representable -- it is only the magnitude of the - // intermediate numerator that is not. Reworking the search onto an + // `Int`'s minimum, -2^127, has no positive counterpart `Int` can hold -- + // its magnitude is the maximum + 1 -- so negating it to reach a positive + // intermediate breaks `Int`'s contract. checked_negate refuses the same + // numerator for the same reason; this follows that precedent rather than + // inventing a second rule for it. Overflow is the honest answer here, not + // Inexact: Inexact means no exact root exists, but the minimum's 127th + // root, -2, both exists and is representable -- it is only the magnitude + // of the intermediate numerator that is not. Reworking the search onto an // unsigned magnitude to rescue this one input would add new numeric code // at the end of a phase to save a single edge case, which risks a worse // bug than the one it fixes. - if (radicand.numerator() == detail::IntMin) + if (radicand.numerator() == std::numeric_limits::min()) return std::unexpected { ArithmeticError::Overflow }; bool const negative = radicand.sign() < 0; Rational::Int const magnitudeNumerator = negative ? -radicand.numerator() : radicand.numerator(); - // At degree 63 or higher, exact_integer_root's binary search is + // At degree 127 or higher, exact_integer_root's binary search is // pathological rather than merely slow: once it probes middle == 1, power // stays 1 for the rest of that probe's inner loop, so the loop runs the // full `degree` multiplications of 1 by 1 before concluding "too small" -- // and degree is an ordinary int a caller controls, so nothing bounds how - // long that takes. The search is also unnecessary at this degree: 2^63 - // alone exceeds IntMax, so no numerator or denominator magnitude of 2 or - // more could have an exact root here -- reaching it would need at least - // 2^63, which Int cannot hold. That leaves only magnitude 0 and 1, both - // fixed points of every power, so the answer is read off directly instead - // of searched for. - if (degree >= 63) + // long that takes. The search is also unnecessary at this degree: 2^127 + // alone exceeds the largest `Int`, so no numerator or denominator + // magnitude of 2 or more could have an exact root here -- reaching it + // would need at least 2^127, which `Int` cannot hold. That leaves only + // magnitude 0 and 1, both fixed points of every power, so the answer is + // read off directly instead of searched for. + if (degree >= 127) { if (magnitudeNumerator > 1 || radicand.denominator() > 1) return std::unexpected { ArithmeticError::Inexact }; diff --git a/include/formula-cpp/render.hpp b/include/formula-cpp/render.hpp index 112c5ebc..4629b0ae 100644 --- a/include/formula-cpp/render.hpp +++ b/include/formula-cpp/render.hpp @@ -501,6 +501,18 @@ namespace detail return std::to_string(declaredNumerator) + "/" + std::to_string(declaredDenominator); } + /// The words of a half-open interval whose bounds are already spelled: + /// `103 to under 197 mm`, or the bounds alone when @p unitSymbol is + /// empty. `band_text` writes every band through it, and so does a trace + /// that shows a band's bounds in another unit than they were declared in + /// (`trace_render.hpp`), so that the one spelling stays one. + [[nodiscard]] inline std::string half_open_text(std::string const& lowText, + std::string const& highText, + std::string_view unitSymbol) + { + return number_with_unit(lowText + " to under " + highText, unitSymbol); + } + /// A half-open band as text: `103 to under 197 mm`. **The one spelling of a /// half-open interval in this library** -- see this file's comment for the /// ruling and for the published defect that bought it. @@ -513,9 +525,9 @@ namespace detail Unit const& keyUnit, NumberStyle numberStyle) { - return number_with_unit( - declared_number_text(shownBand.lowNumerator, shownBand.lowDenominator, keyUnit, numberStyle) + " to under " - + declared_number_text(shownBand.highNumerator, shownBand.highDenominator, keyUnit, numberStyle), + return half_open_text( + declared_number_text(shownBand.lowNumerator, shownBand.lowDenominator, keyUnit, numberStyle), + declared_number_text(shownBand.highNumerator, shownBand.highDenominator, keyUnit, numberStyle), keySymbol); } diff --git a/include/formula-cpp/rounded_root.hpp b/include/formula-cpp/rounded_root.hpp index c597154e..8d8ba23c 100644 --- a/include/formula-cpp/rounded_root.hpp +++ b/include/formula-cpp/rounded_root.hpp @@ -88,48 +88,20 @@ namespace detail }; /// @p leftFactor times @p rightFactor, or nothing when the product leaves - /// `std::uint64_t`. - [[nodiscard]] constexpr std::optional mul_unsigned_or_none(std::uint64_t leftFactor, - std::uint64_t rightFactor) noexcept + /// 128 bits. + [[nodiscard]] constexpr std::optional mul_unsigned_or_none(UInt128 leftFactor, UInt128 rightFactor) noexcept { - if (leftFactor != 0 && rightFactor > std::numeric_limits::max() / leftFactor) - return std::nullopt; - FORMULA_CENSUS_NOTE(Unsigned, leftFactor * rightFactor); - return leftFactor * rightFactor; + std::optional const product = u128_mul_checked(leftFactor, rightFactor); + if (product) + FORMULA_CENSUS_NOTE(Unsigned, *product); + return product; } /// 10 to the @p exponent, for a non-negative @p exponent, or nothing when - /// it leaves `std::uint64_t`. - [[nodiscard]] constexpr std::optional unsigned_pow10(std::int64_t exponent) noexcept + /// it leaves 128 bits. + [[nodiscard]] constexpr std::optional unsigned_pow10(std::int64_t exponent) noexcept { - std::uint64_t raisedSoFar = 1; - for (std::int64_t multiplied = 0; multiplied < exponent; ++multiplied) - { - std::optional const raised = mul_unsigned_or_none(raisedSoFar, 10); - if (!raised) - return std::nullopt; - raisedSoFar = *raised; - } - return raisedSoFar; - } - - /// The largest `f` with `f * f <= radicand`, by binary search. Every such - /// `f` is below 2^32, so `f * f` never leaves `std::uint64_t`. - [[nodiscard]] constexpr std::uint64_t integer_square_root(std::uint64_t radicand) noexcept - { - if (radicand < 2) - return radicand; - std::uint64_t below = 1; - std::uint64_t above = std::uint64_t { 1 } << 32; - while (below + 1 < above) - { - std::uint64_t const middle = below + (above - below) / 2; - if (middle <= radicand / middle) - below = middle; - else - above = middle; - } - return below; + return exponent < 0 || exponent > 38 ? std::nullopt : u128_pow10(static_cast(exponent)); } /// The square root of @p radicandInUnitSquared, rounded to @p places @@ -163,12 +135,12 @@ namespace detail /// does for `checked_round`: then S is 1/10^(2|p|), and it is B that grows /// instead of q. /// - /// Every intermediate is a `std::uint64_t`; there is no 128-bit integer, - /// because cl has none. So the headroom is `floor(v) * 10^(2p) < 2^64` - /// (and `b * 10^(2p) < 2^64` for the remainder): an integer radicand of - /// about 10^6 fits at 6 places and overflows at 7. **The bound is on the + /// Every intermediate is a 128-bit unsigned integer (`detail::UInt128`). + /// So the headroom is `floor(v) * 10^(2p) < 2^128` (and + /// `b * 10^(2p) < 2^128` for the remainder): an integer radicand of about + /// 10^6 fits at 16 places and overflows at 17. **The bound is on the /// denominator b too**, whatever the value: at p places a denominator above - /// about 1.8 * 10^(19 - 2|p|) overflows, at a negative p because B is + /// about 3.4 * 10^(38 - 2|p|) overflows, at a negative p because B is /// b * 10^(2|p|), even where the rounded answer itself would fit. Beyond /// the headroom the answer is `ArithmeticError::Overflow`, never a wrapped /// or clamped value. @@ -188,50 +160,53 @@ namespace detail if (exactRoot.error() != ArithmeticError::Inexact) return std::unexpected { exactRoot.error() }; - auto const wholeNumerator = static_cast(radicandInUnitSquared.numerator()); - auto const wholeDenominator = static_cast(radicandInUnitSquared.denominator()); + UInt128 const wholeNumerator = wide_magnitude(radicandInUnitSquared.numerator()); + UInt128 const wholeDenominator = wide_magnitude(radicandInUnitSquared.denominator()); auto const doubledPlaces = std::int64_t { 2 } * places.value; // v * S = wholePart + leftover / divisor. - std::uint64_t wholePart = 0; - std::uint64_t leftover = 0; - std::uint64_t divisor = wholeDenominator; + UInt128 wholePart {}; + UInt128 leftover {}; + UInt128 divisor = wholeDenominator; if (doubledPlaces >= 0) { - std::optional const powerOfTen = unsigned_pow10(doubledPlaces); + std::optional const powerOfTen = unsigned_pow10(doubledPlaces); if (!powerOfTen) return std::unexpected { ArithmeticError::Overflow }; - std::optional const scaledWhole = - mul_unsigned_or_none(wholeNumerator / wholeDenominator, *powerOfTen); + UInt128Division const split = u128_divmod(wholeNumerator, wholeDenominator); + std::optional const scaledWhole = mul_unsigned_or_none(split.quotient, *powerOfTen); // Below wholeDenominator * scale, so it fits whenever that does. - std::optional const scaledPart = - mul_unsigned_or_none(wholeNumerator % wholeDenominator, *powerOfTen); + std::optional const scaledPart = mul_unsigned_or_none(split.remainder, *powerOfTen); if (!scaledWhole || !scaledPart) return std::unexpected { ArithmeticError::Overflow }; - wholePart = *scaledWhole + *scaledPart / wholeDenominator; + UInt128Division const partSplit = u128_divmod(*scaledPart, wholeDenominator); + wholePart = u128_add(*scaledWhole, partSplit.quotient); if (wholePart < *scaledWhole) return std::unexpected { ArithmeticError::Overflow }; FORMULA_CENSUS_NOTE(Unsigned, wholePart); - leftover = *scaledPart % wholeDenominator; + leftover = partSplit.remainder; } else { - std::optional const shrink = unsigned_pow10(-doubledPlaces); - std::optional const widened = - shrink ? mul_unsigned_or_none(wholeDenominator, *shrink) : std::nullopt; + std::optional const shrink = unsigned_pow10(-doubledPlaces); + std::optional const widened = shrink ? mul_unsigned_or_none(wholeDenominator, *shrink) : std::nullopt; if (!widened) return std::unexpected { ArithmeticError::Overflow }; divisor = *widened; - wholePart = wholeNumerator / divisor; - leftover = wholeNumerator % divisor; + UInt128Division const split = u128_divmod(wholeNumerator, divisor); + wholePart = split.quotient; + leftover = split.remainder; } - std::uint64_t const floorDigits = integer_square_root(wholePart); - // Below (floorDigits + 1)^2 <= 2^64, so it fits. - std::uint64_t const halfwayWhole = floorDigits * floorDigits + floorDigits; - bool const aboveHalfway = wholePart > halfwayWhole || (wholePart == halfwayWhole && leftover > divisor / 4); + std::uint64_t const floorDigits = u128_isqrt(wholePart); + // f^2 + f is below (f + 1)^2 <= 2^128, so it fits. + UInt128 const halfwayWhole = u128_add(u128_mul_words(floorDigits, floorDigits), UInt128::from_u64(floorDigits)); + bool const aboveHalfway = + halfwayWhole < wholePart + || (wholePart == halfwayWhole && u128_divmod(divisor, UInt128::from_u64(4)).quotient < leftover); - std::uint64_t keptDigits = floorDigits; + UInt128 keptDigits = UInt128::from_u64(floorDigits); + UInt128 const raisedDigits = u128_add(keptDigits, UInt128::from_u64(1)); switch (roundingMode) { case RoundingMode::Floor: @@ -239,17 +214,17 @@ namespace detail break; case RoundingMode::Ceiling: case RoundingMode::AwayFromZero: - keptDigits = floorDigits + 1; + keptDigits = raisedDigits; break; case RoundingMode::HalfAwayFromZero: case RoundingMode::HalfTowardZero: case RoundingMode::HalfEven: - keptDigits = aboveHalfway ? floorDigits + 1 : floorDigits; + keptDigits = aboveHalfway ? raisedDigits : keptDigits; break; } - // At most 2^32, so the conversion is exact. - return Rational::from_decimal(static_cast(keptDigits), -places.value); + // At most 2^64, which `Rational::Int` holds. + return Rational::from_decimal(signed_from_magnitude(keptDigits, false), -places.value); } /// The square root of @p radicandInSi, a value in the coherent unit of @@ -271,12 +246,21 @@ namespace detail if (!factorSquared) return factorSquared; + // A `Unit` holds its magnitude in 64 bits: a factor or a square wider + // than that is beyond any scale this can build. + std::optional const factorTop = narrow_to_int64(unitFactor->numerator()); + std::optional const factorBottom = narrow_to_int64(unitFactor->denominator()); + std::optional const squaredTop = narrow_to_int64(factorSquared->numerator()); + std::optional const squaredBottom = narrow_to_int64(factorSquared->denominator()); + if (!factorTop || !factorBottom || !squaredTop || !squaredBottom) + return std::unexpected { ArithmeticError::Overflow }; + Unit const unitScale { .dimension = unit.dimension, - .magnitudeNumerator = unitFactor->numerator(), - .magnitudeDenominator = unitFactor->denominator() }; + .magnitudeNumerator = *factorTop, + .magnitudeDenominator = *factorBottom }; Unit const scaleSquared { .dimension = unit.dimension * unit.dimension, - .magnitudeNumerator = factorSquared->numerator(), - .magnitudeDenominator = factorSquared->denominator() }; + .magnitudeNumerator = *squaredTop, + .magnitudeDenominator = *squaredBottom }; std::expected const inUnitSquared = checked_convert(radicandInSi, coherent(scaleSquared.dimension), scaleSquared); diff --git a/include/formula-cpp/rounded_transcendental.hpp b/include/formula-cpp/rounded_transcendental.hpp index a3eb9c80..b021f213 100644 --- a/include/formula-cpp/rounded_transcendental.hpp +++ b/include/formula-cpp/rounded_transcendental.hpp @@ -43,11 +43,13 @@ namespace detail /// rounded decimal of the true value, rational or not. The decision, in order: a logarithm of zero or /// below is `DomainError`; places outside -18...18 are `Overflow`, as for `checked_round`; a special /// point -- the only values that can tie -- goes to `checked_round`; the exponential of more than 44 - /// is `Overflow` (every rounding of exp 44 exceeds `Rational::Int`), and of less than -43 is below a + /// is `Overflow` (past the kernel's range, which ends at exp 44 = 1.29 * 10^19), and of less than -43 is below a /// quarter of the last kept unit at any places accepted, so 0, or one unit under `Ceiling` and /// `AwayFromZero`; everything else is the kernel's enclosure, rounded by `decide_rounding`, which /// answers `Overflow` when the kept integer does not fit and when the two ends round differently. - /// The special points are `RepFunctions`'s, through `transcendental_of`: their value, and + /// The kernel takes an argument whose numerator and denominator each fit 64 bits, the range it was + /// built for; a wider argument that reaches it, which a `Rational` can hold, is `Overflow` too. The + /// special points are `RepFunctions`'s, through `transcendental_of`: their value, and /// `Inexact` elsewhere. template [[nodiscard]] constexpr std::expected rounded_transcendental( @@ -119,6 +121,10 @@ struct RoundedTranscendentalNode: NodeBase /// The natural logarithm of `operand`, a dimensionless expression, rounded exactly to `Places` decimal /// places: `rounded_ln(var / var)`. +/// +/// The integer kernel (`detail/transcendental.hpp`) takes an argument whose numerator and denominator +/// each fit 64 bits: a wider one, though a `Rational` holds it, is `Overflow`, as is a rounding the +/// kernel cannot decide. template [[nodiscard]] constexpr auto rounded_ln(Operand operand) noexcept { @@ -126,6 +132,10 @@ template } /// The decimal logarithm of `operand`, rounded exactly to `Places` decimal places. +/// +/// As for `rounded_ln`, an argument whose numerator or denominator does not fit 64 bits is `Overflow`, as +/// log10 2^70 is -- except a power of ten, 10^19 up to 10^38 or one over it, which is answered exactly +/// before the kernel is asked: log10 10^30 is 30. template [[nodiscard]] constexpr auto rounded_log10(Operand operand) noexcept { @@ -133,6 +143,12 @@ template } /// The exponential of `operand`, rounded exactly to `Places` decimal places. +/// +/// Its range is the integer kernel's (`detail/transcendental.hpp`), checked in this order: an argument +/// above 44 is `Overflow`; one below -43 is 0, or one last kept unit under `Ceiling` and `AwayFromZero`, +/// whatever its width, so exp(-2^70) is 0; and an argument between them whose numerator or denominator +/// does not fit 64 bits is `Overflow`, though a `Rational` holds it. A rounding the kernel cannot decide +/// is `Overflow` too, never a guess. template [[nodiscard]] constexpr auto rounded_exp(Operand operand) noexcept { diff --git a/include/formula-cpp/rounding.hpp b/include/formula-cpp/rounding.hpp index f5865a65..6d84fe49 100644 --- a/include/formula-cpp/rounding.hpp +++ b/include/formula-cpp/rounding.hpp @@ -16,6 +16,8 @@ #include #include +#include +#include #include namespace formula @@ -209,31 +211,31 @@ namespace detail { /// Whether `|numerator| / denominator >= 10^exponent`, exactly and without /// ever constructing 10^exponent as a Rational -- which is impossible at the - /// extremes of the representable range. - [[nodiscard]] constexpr bool at_least_pow10(std::uint64_t magnitudeNumerator, - std::uint64_t magnitudeDenominator, + /// extremes of the representable range. A scaled side beyond the largest + /// `Rational::Int` already decides the comparison, and is never formed. + [[nodiscard]] constexpr bool at_least_pow10(UInt128 magnitudeNumerator, + UInt128 magnitudeDenominator, int exponent) noexcept { - constexpr std::uint64_t Limit = static_cast(IntMax); + constexpr UInt128 Largest = wide_magnitude(std::numeric_limits::max()); if (exponent >= 0) { - if (exponent > 18) + std::optional const powerOfTen = u128_pow10(exponent); + std::optional const scaledDenominator = + powerOfTen ? u128_mul_checked(magnitudeDenominator, *powerOfTen) : std::nullopt; + // Beyond the largest numerator, the quotient is below 10^exponent. + if (!scaledDenominator || Largest < *scaledDenominator) return false; - std::uint64_t const powerOfTen = static_cast(*pow10(exponent)); - // denominator * factor > Limit implies the scaled denominator already - // exceeds any possible numerator, so the quotient is below 10^exponent. - if (magnitudeDenominator > Limit / powerOfTen) - return false; - FORMULA_CENSUS_NOTE(Intermediate, magnitudeDenominator * powerOfTen); - return magnitudeNumerator >= magnitudeDenominator * powerOfTen; + FORMULA_CENSUS_NOTE(Intermediate, *scaledDenominator); + return !(magnitudeNumerator < *scaledDenominator); } - if (-exponent > 18) - return true; - std::uint64_t const powerOfTen = static_cast(*pow10(-exponent)); - if (magnitudeNumerator > Limit / powerOfTen) + std::optional const powerOfTen = u128_pow10(-exponent); + std::optional const scaledNumerator = + powerOfTen ? u128_mul_checked(magnitudeNumerator, *powerOfTen) : std::nullopt; + if (!scaledNumerator || Largest < *scaledNumerator) return true; - FORMULA_CENSUS_NOTE(Intermediate, magnitudeNumerator * powerOfTen); - return magnitudeNumerator * powerOfTen >= magnitudeDenominator; + FORMULA_CENSUS_NOTE(Intermediate, *scaledNumerator); + return !(*scaledNumerator < magnitudeDenominator); } } // namespace detail @@ -245,8 +247,8 @@ namespace detail if (examinedValue.is_zero()) return std::unexpected { ArithmeticError::DomainError }; - std::uint64_t const magnitudeNumerator = detail::magnitude(examinedValue.numerator()); - auto const magnitudeDenominator = static_cast(examinedValue.denominator()); + detail::UInt128 const magnitudeNumerator = detail::wide_magnitude(examinedValue.numerator()); + detail::UInt128 const magnitudeDenominator = detail::wide_magnitude(examinedValue.denominator()); // The digit counts bracket the answer to within one: with dn digits in the // numerator and dd in the denominator, floor(log10(n/d)) is either @@ -317,25 +319,26 @@ namespace detail /// wrong number: /// /// - `from_double_exact` needs the double's exact binary value to be -/// representable, which fails outright for a full-mantissa value below -/// `2^-10`, about 0.00098. Measured: `0.0009765625` converts, `0.0001` does not. +/// representable: its denominator, a power of two, must stay below 2^127. +/// That limit is set by the value's magnitude. Measured: `0.0001`, a 53-bit +/// numerator over 2^66, converts; `1e-30`, over 2^147, does not. /// - Rounding to a POSITIVE number of places `N` scales by `10^N`, cancelling /// common factors of two against the denominator first, so what must fit in -/// `Int` is `|numerator| * (10^N / gcd(10^N, denominator))` -- for a binary -/// denominator, `|numerator| * 5^N`. There the limit is set by the -/// **numerator's** magnitude, not the denominator's and not the value's size: -/// `1 / 2^60` rounds at all 18 places, while `8106479329266893 / 2^54` -/// manages 4. A `double`'s mantissa is always about 53 bits whatever its -/// exponent, so every value from this function caps out at 4 decimal places. -/// A value built with `from_decimal` has a tiny numerator and is unaffected -/// at positive places. +/// `Rational::Int` is `|numerator| * (10^N / gcd(10^N, denominator))` -- for +/// a binary denominator, `|numerator| * 5^N`. There the limit is set by the +/// **numerator's** magnitude, not the denominator's and not the value's +/// size: `1 / 2^121` rounds at all 18 places, while a 100-bit numerator over +/// the same denominator does not. A `double` below 2^53 in magnitude has a +/// numerator of at most 53 bits, and rounding it forms at most +/// 2^53 * 5^18 * 2^18, below 2^113, so it rounds at every place from 0 to +/// 18. A whole `double` past that has a numerator of its own magnitude and +/// nothing to cancel: 1e21 is refused at 18 places, and 1e38 at 1. /// - Rounding to a NEGATIVE number of places -- to whole tens or hundreds -- /// uses an integer step, which multiplies the **denominator** instead. There -/// the denominator is the constraint, and the two cases invert: `1/10^18` is -/// refused at every negative place while `1/3` handles all of them. +/// the denominator is the constraint: `2^-100` is refused at -18 places. /// /// Prefer `from_decimal` for an exact decimal; use this function only for a -/// genuinely measured `double`, and only at modest decimal precision. +/// genuinely measured `double`. [[nodiscard]] constexpr std::expected rational_from_double(double floating, DecimalPlaces places, RoundingMode roundingMode) noexcept diff --git a/include/formula-cpp/statistics.hpp b/include/formula-cpp/statistics.hpp index 0e1bd924..77560ed0 100644 --- a/include/formula-cpp/statistics.hpp +++ b/include/formula-cpp/statistics.hpp @@ -472,16 +472,18 @@ template [[nodiscard]] constexpr Evaluated checked_evaluate_si(SampleVarianceNode const& node, diff --git a/include/formula-cpp/trace.hpp b/include/formula-cpp/trace.hpp index a62711f8..51da8088 100644 --- a/include/formula-cpp/trace.hpp +++ b/include/formula-cpp/trace.hpp @@ -306,7 +306,8 @@ enum class StepKind : std::uint8_t /// A `SampleSizeLookupNode`: a critical value read from an author's table /// by sample size (`critical_value.hpp`). A lookup like the three above: /// `Step::lookupFailure` says whose failure a failed step carries, - /// `Step::lookupKey` holds the count it selected with, and + /// `Step::lookupKey` holds the count it selected with (its high word, + /// for a count past 2^64 - 1, in `Step::lookupKeyHigh`), and /// `Trace::sampleSizeRecords` the sizes the table declares, keyed by the /// step's index, so that a miss can say which counts would have hit. /// @@ -356,8 +357,8 @@ enum class StepKind : std::uint8_t SampleMean, /// A `SampleVarianceNode`: the sample variance, over n - 1. Its one /// operand is the sample's own step. Shown in the coherent unit of its - /// squared dimension, as every computed step is: no declared unit names - /// a squared mass. + /// squared dimension, as a computed step with no unit to borrow is: no + /// operand's unit names a squared mass. /// /// Checked on GCC under `-Wshadow`: the node is `SampleVarianceNode` and /// the factory `sample_variance`, so nothing in namespace `formula` is @@ -967,22 +968,41 @@ struct Step /// The dimension of what this step produced. Dimension dimension {}; - /// The unit this step's value was **declared** in -- `Describe::unit` - /// for a variable or an overridden constant, the constant's own unit for - /// a constant, the node's own unit for a `Round`, `RoundSignificant`, - /// `RoundedRoot`, `RoundedOpaqueOutput` or `RoundingRuleApplied` step, the - /// unit of the step it wraps for a `Documented`, `ReplacedVariant` or - /// `VariantSelected` step -- - /// each passes its operand's value through unchanged, so it states it as - /// that operand's line does, whenever that line is the wrapped node's own - /// and not the operands of a consumer's node -- and the coherent unit of - /// `dimension` for anything else computed, which has no declared unit of - /// its own. + /// The unit this step's value is shown in: + /// + /// - a variable, constant or rounding shows its declared unit -- + /// `Describe::unit` for a variable or an overridden constant, the + /// constant's own unit for a constant, the node's own unit for a + /// `Round`, `RoundSignificant`, `RoundedRoot`, `RoundedOpaqueOutput` or + /// `RoundingRuleApplied` step; + /// - a `Documented`, `ReplacedVariant`, `VariantSelected` or `RecordScope` + /// step passes its operand's value through unchanged, so it shows the + /// unit that operand's line does, whenever that line is the wrapped + /// node's own and not the operands of a consumer's node; + /// - a value scaled by a pure number shows its operand's unit, and so + /// does a sum or difference on one scale under one name, a series' sum + /// and its range; + /// - a negation and an absolute value show their operand's unit; + /// - a mean and a rejection pass's mean are points on their sample's + /// scale, and show its unit, offset or not; + /// - a conditional and a precision limit show the unit of the step they + /// restate; + /// - an opaque operation's output shows an input's unit of its + /// dimension, or a quotient of two (`OpaqueOutputValue::unit`); + /// - an offset unit is never borrowed for a sum, difference, scaling, + /// negation or absolute value: such a value is no point on its scale; + /// - everything else is the coherent unit of `dimension`, which the + /// renderer writes after the number, spelt from its bases (`kg/m^3`). + /// + /// A unit is borrowed only from operand steps that are provably the + /// operands' own, and only when it has a symbol: a value in a unit with + /// no symbol could not say what scale it is on, and reads in the coherent + /// unit instead. /// /// `value` is always in the coherent unit, so that steps are /// comparable; this is what a renderer converts back to before showing a /// number to a person. Without it a derivation restates every input in a - /// unit nobody typed: someone who entered 180 l reads `9/50`, which is + /// unit nobody typed: someone who entered 180 l reads `9/50 m^3`, which is /// the same volume and a worse record. The renderer cannot recover this /// on its own -- by the time a `Step` exists the quantity type is erased, /// so the recorder captures it here. @@ -1155,15 +1175,24 @@ struct Step /// the same case `key_text` spells its two casts separately for. /// /// For `SampleSizeLookup`: the count this lookup selected with, when it - /// was a whole, non-negative number, read unsigned. A count that was not - /// one (`LookupFailure::NotACount`) leaves this zero, and its value stays - /// in the operand's own step, where the renderer points. + /// was a whole, non-negative number, read unsigned -- its low 64 bits, + /// with the rest in `lookupKeyHigh` below. A count that was not one + /// (`LookupFailure::NotACount`) leaves both zero, and its value stays in + /// the operand's own step, where the renderer points. std::uint64_t lookupKey {}; /// Whether `lookupKey` above is to be read as a signed value. Meaningful - /// only when `kind` is `ExactLookup`, exactly as `lookupKey` itself is. + /// only when `kind` is `ExactLookup`. `lookupKey` also carries a + /// `SampleSizeLookup`'s count, which is always read unsigned and leaves + /// this false. bool lookupKeyIsSigned {}; + /// For `SampleSizeLookup`: bits 64 to 127 of the count, whose low 64 bits + /// are `lookupKey`'s. Zero for every count a table can declare; a count + /// past 2^64 - 1 misses every table, and the line spells it in full from + /// the two. Zero for every other kind. + std::uint64_t lookupKeyHigh {}; + /// For `ExactLookup`: the name of the key this lookup selected with -- /// `Cylinder`, or the author's own spelling of it through /// `EnumeratorName` (`enumerator.hpp`) -- and empty when the key names no @@ -2122,7 +2151,8 @@ namespace detail /// shown in degrees Celsius it would be off by the offset. Not when it has /// no symbol: the value could not say what scale it is on, and would read /// as the coherent unit every unlabelled computed value is shown in. The - /// value then reads in the coherent unit, as every computed value does. + /// value then reads in the coherent unit, which the renderer names, as + /// every computed value with no unit to borrow does. [[nodiscard]] constexpr bool borrowable(Unit const& shownUnit) noexcept { return shownUnit.offsetNumerator == 0 && !view(shownUnit.symbolText).empty(); @@ -2155,6 +2185,20 @@ namespace detail return operandUnit.dimension == dimension && borrowable(operandUnit) ? operandUnit : fallback; } + /// Whether @p leftUnit and @p rightUnit show values on one scale under one + /// 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==`. + [[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); + } + /// Whether `RecordingSink` records a step of its own for @p N, a single /// value's node or a series': `RecordsStep`, extended to the series kinds /// `SeriesStepKindOf` describes. @@ -2222,23 +2266,119 @@ namespace detail } } - /// Which operand of @p S, an elementwise binary node, its values are that - /// operand's scaled by a pure number -- 0 for the left, 1 for the right -- - /// so that they read in its unit: the non-dimensionless side of a product - /// with exactly one dimensionless side, and the left of a quotient by a - /// dimensionless right. Empty for every other kind, and for a product of - /// two pure numbers, which says nothing about which one's unit it is in. + /// Which operand of @p N, a binary node whose operator is @p Op, its + /// values are that operand's scaled by a pure number -- 0 for the left, 1 + /// for the right -- so that they read in its unit: the non-dimensionless + /// side of a product with exactly one dimensionless side, and the left of + /// a quotient by a dimensionless right. Empty for every other operator, + /// and for a product of two pure numbers, which says nothing about which + /// one's unit it is in. Read off `BinarySides`, so a single value's node + /// and an elementwise one answer alike. + template + inline constexpr std::optional scaled_side = + Op == BinaryOperator::Multiply && BinarySides::left::dimension == dim::Scalar + && !(BinarySides::right::dimension == dim::Scalar) + ? std::optional { 1 } + : (Op == BinaryOperator::Multiply || Op == BinaryOperator::Divide) + && BinarySides::right::dimension == dim::Scalar && !(BinarySides::left::dimension == dim::Scalar) + ? std::optional { 0 } + : std::nullopt; + + /// Which operand of @p S, a binary node -- a single value's or an + /// elementwise one -- its values are that operand's scaled by a pure + /// number, as `scaled_side` rules. Empty for every other kind. template inline constexpr std::optional scaled_operand = std::nullopt; template inline constexpr std::optional scaled_operand> = - Op == BinaryOperator::Multiply && Left::dimension == dim::Scalar && !(Right::dimension == dim::Scalar) - ? std::optional { 1 } - : (Op == BinaryOperator::Multiply || Op == BinaryOperator::Divide) && Right::dimension == dim::Scalar - && !(Left::dimension == dim::Scalar) - ? std::optional { 0 } - : std::nullopt; + scaled_side>; + + template + inline constexpr std::optional scaled_operand> = + scaled_side>; + + /// The unit a single value's binary step is shown in. For a product with + /// exactly one pure number, or a quotient by one, it is the other + /// operand's unit: 3/50 of a mean in grams is grams. For a sum or a + /// difference of two values shown on one scale under one name, it is + /// that unit, at the finer of their two declared precisions. Either way + /// only when each side recorded the one step claimed for it, and the + /// unit is `borrowable` and of the step's own dimension -- read off the + /// operand steps, never off a type, so that what they show is what + /// carries over. @p fallback otherwise: the coherent unit, which the + /// renderer names. + template + [[nodiscard]] constexpr Unit binary_unit_or(std::vector> const& steps, + std::vector const& operands, + Unit fallback) noexcept + { + if constexpr (!RecordsOwnStep::left> || !RecordsOwnStep::right>) + return fallback; + else + { + if (operands.size() != 2) + return fallback; + Unit const& leftUnit = steps[operands[0]].unit; + Unit const& rightUnit = steps[operands[1]].unit; + if constexpr (scaled_operand.has_value()) + { + Unit const& scaledUnit = *scaled_operand == 0 ? leftUnit : rightUnit; + return scaledUnit.dimension == N::dimension && borrowable(scaledUnit) ? scaledUnit : fallback; + } + else if constexpr (StepKindOf::value == StepKind::Add || StepKindOf::value == StepKind::Subtract) + { + if (!(leftUnit.dimension == N::dimension) || !borrowable(leftUnit) + || !same_scale_and_symbol(leftUnit, rightUnit)) + return fallback; + Unit shared = leftUnit; + shared.decimals = leftUnit.decimals < rightUnit.decimals ? rightUnit.decimals : leftUnit.decimals; + return shared; + } + else + return fallback; + } + } + + /// The one operand type of a negation or an absolute value. Undefined + /// for every other kind. + template + struct UnarySide; + + template + struct UnarySide> + { + using inner = Operand; + }; + + template + struct UnarySide> + { + using inner = Operand; + }; + + /// The unit of a step whose value restates its last claimed step's -- a + /// conditional's chosen branch, a precision limit's second pass: that + /// step's unit, when it has a symbol, is of @p dimension, and holds + /// exactly @p restatedValue. The value is a point on that step's scale, + /// so an offset unit may be shown (`borrowable_for_a_point`). Comparing + /// the values means the unit can never be claimed for a number it is not. + /// @p fallback otherwise. + template + [[nodiscard]] constexpr Unit restated_unit_or(std::vector> const& steps, + std::vector const& operands, + Dimension dimension, + std::optional const& restatedValue, + Unit fallback) noexcept + { + if (operands.empty() || !restatedValue.has_value()) + return fallback; + Step const& lastClaimed = steps[operands.back()]; + if (!(lastClaimed.dimension == dimension) || !lastClaimed.value.has_value() + || !(*lastClaimed.value == *restatedValue) || !borrowable_for_a_point(lastClaimed.unit)) + return fallback; + return lastClaimed.unit; + } template struct StepKindOf> @@ -2545,7 +2685,11 @@ namespace detail checked_convert(point, coherent(pointUnit.dimension), pointUnit); if (!stated.has_value()) return std::nullopt; - return Breakpoint { stated->numerator(), stated->denominator() }; + std::optional const keyTop = narrow_to_int64(stated->numerator()); + std::optional const keyBottom = narrow_to_int64(stated->denominator()); + if (!keyTop || !keyBottom) + return std::nullopt; + return Breakpoint { *keyTop, *keyBottom }; } /// Fills in an interpolation step along a curve: its values' unit and its @@ -2722,12 +2866,17 @@ namespace detail Rational { under.magnitudeNumerator, under.magnitudeDenominator }); if (!magnitude.has_value()) return std::nullopt; + // A `Unit` holds its magnitude in 64 bits: a wider one is no unit. + std::optional const magnitudeTop = narrow_to_int64(magnitude->numerator()); + std::optional const magnitudeBottom = narrow_to_int64(magnitude->denominator()); + if (!magnitudeTop || !magnitudeBottom) + return std::nullopt; MergedDimension const quotientDimension = merged_dimension(over.dimension, under.dimension, true); if (!quotientDimension.fits) return std::nullopt; Unit quotientUnit { .dimension = quotientDimension.dimension, - .magnitudeNumerator = magnitude->numerator(), - .magnitudeDenominator = magnitude->denominator(), + .magnitudeNumerator = *magnitudeTop, + .magnitudeDenominator = *magnitudeBottom, .decimals = over.decimals < under.decimals ? under.decimals : over.decimals }; std::size_t written = 0; for (char const spelt: overSymbol) @@ -2759,9 +2908,9 @@ namespace detail /// reading on its scale -- a span of Celsius readings is a difference, and /// shown in degrees Celsius it would be off by the offset -- so it reads /// in kelvin. A unit with no symbol is never borrowed: its value could - /// not say what scale it is on, and the trace spells a unit it cannot - /// name as the coherent one -- a consumer's unnamed thousandth of a - /// metre would read as metres, a thousand times too large. And a + /// not say what scale it is on, and the trace shows a value in such a + /// unit in the coherent one anyway -- a consumer's unnamed thousandth of + /// a metre reads as metres. And a /// dimensionless output borrows nothing: a ratio of two masses is not a /// percentage because some input was one, and an operation declares no /// unit for its outputs. @@ -2844,13 +2993,16 @@ namespace detail return; } - std::optional const sampleSize = as_sample_size(*operandValue); + std::optional const sampleSize = as_sample_size(*operandValue); if (!sampleSize.has_value()) { step.lookupFailure = LookupFailure::NotACount; return; } - step.lookupKey = *sampleSize; + // All of the count, in two words: one beyond 2^64 - 1 is one no + // table declares, and its miss still names it. + step.lookupKey = sampleSize->lowWord; + step.lookupKeyHigh = sampleSize->highWord; if (!find_sample_size(*sampleSize).has_value()) step.lookupFailure = LookupFailure::Missed; @@ -3091,7 +3243,8 @@ class RecordingSink nodeStep.dimension = N::dimension; // Anything computed has no declared unit, so the coherent one is - // the truthful answer; a variable overrides it with the unit its + // the truthful answer -- until the rules below borrow one from the + // operand steps; a variable overrides it with the unit its // quantity is declared in. `requires { N::unit; }` now also selects // `ConstantNode`, `RoundNode`, `RoundSignificantNode`, // `RoundedRootNode` and `RoundedOpaqueOutputNode` -- every one of them @@ -3262,15 +3415,35 @@ class RecordingSink sampleUnit.dimension == nodeStep.dimension && detail::borrowable_for_a_point(sampleUnit)) nodeStep.unit = sampleUnit; - // Which side a binary step's operand stood on, when it has one. + // Which side a binary step's operand stood on, when it has one, and + // the unit it is shown in: its scaled operand's or its operands' + // shared one, when `binary_unit_or` finds one. if constexpr (detail::StepKindOf::value == StepKind::Add || detail::StepKindOf::value == StepKind::Subtract || detail::StepKindOf::value == StepKind::Multiply || detail::StepKindOf::value == StepKind::Divide) + { detail::record_operand_sides(nodeStep, _trace->steps); + nodeStep.unit = detail::binary_unit_or(_trace->steps, nodeStep.operands, nodeStep.unit); + } + + // A negation and an absolute value are on their operand's scale: + // -(3 g) is -3 g. Not on an offset one's: -(20 degC) is no reading at + // -20 degC (`borrowable`). + if constexpr (requires { typename detail::UnarySide::inner; }) + if constexpr (detail::RecordsOwnStep::inner>) + nodeStep.unit = + detail::operand_unit_or(_trace->steps, nodeStep.operands, nodeStep.dimension, nodeStep.unit); + + // A conditional's value is its chosen branch's, and a precision + // limit's is its second pass's: each reads in that step's unit. + if constexpr (detail::StepKindOf::value == StepKind::Conditional + || detail::StepKindOf::value == StepKind::PrecisionLimit) + nodeStep.unit = detail::restated_unit_or(_trace->steps, nodeStep.operands, nodeStep.dimension, nodeStep.value, + nodeStep.unit); // A read from another record is its operand's value, unchanged, so it // reads in the unit its operand's line does: `4 MPa` after a variable - // or a rounding in MPa, and the coherent unit after a computation -- + // or a rounding in MPa, and whatever unit a computation's line shows -- // never the same value in two scales on consecutive lines. The operand // is the last step claimed that is not a lineage attribute; a scope // over an unbound record claims none, reads nothing, and keeps the @@ -3810,8 +3983,8 @@ class RecordingSink seriesStep.inputSource = _trace->pendingInputSource; } // A per-element constant is shown in the unit it was written in; a - // computed series has no declared unit, as a computed scalar has - // none, and keeps the coherent one. + // computed series keeps the coherent one, as a computed scalar does, + // until the rules below borrow one from its operand steps. else if constexpr (detail::SeriesStepKindOf::value == StepKind::SeriesConstant || detail::SeriesStepKindOf::value == StepKind::SeriesDomain) seriesStep.unit = S::unit; diff --git a/include/formula-cpp/trace_render.hpp b/include/formula-cpp/trace_render.hpp index e4a27db5..2993ba8c 100644 --- a/include/formula-cpp/trace_render.hpp +++ b/include/formula-cpp/trace_render.hpp @@ -22,11 +22,15 @@ /// unbounded render of a derivation with a hundred thousand steps is one /// unusable wall of text; a default limit is a limit someone forgets, and /// a required one is a limit someone chooses. -/// - It never shows a value in a unit nobody entered. Every `Step` holds its -/// value in the coherent unit of its dimension so that steps are -/// comparable, and remembers the unit it was *declared* in; this converts -/// back before showing a number, so a volume entered as 180 l reads -/// `180 l` and not `9/50`. +/// - It never shows a number without the unit it is in. Every `Step` holds +/// its value in the coherent unit of its dimension so that steps are +/// comparable, and remembers the unit it is shown in (`Step::unit`): the +/// one it was *declared* in, one borrowed from its operands where that is +/// safe, or the coherent unit. This converts back before showing a number +/// and writes that unit after it, so a volume entered as 180 l reads +/// `180 l` and not `9/50 m^3`, and a computed density whose unit has no +/// symbol reads in the coherent unit, spelt from its base units: +/// `2400 kg/m^3`. Only a dimensionless value is a bare number. /// - It never shows an approximation as exact. Numbers are fractions unless /// `TraceRenderOptions::numbers` asks for decimals, and a decimal is shown /// only where it is the exact value -- `3/5` reads `0.6`, `1/3` stays @@ -35,8 +39,9 @@ /// are never rounded even then: a number typed rather than computed -- a /// constant, a table's row or bound, a permitted value, a limit, and a /// step that only passes one on -- and either side of a comparison a line -/// states beside its verdict. A value in a unit nobody declared, a -/// computed product or ratio, is never padded with zeros. +/// states beside its verdict. A value in the coherent unit -- a computed +/// product or ratio, followed by its spelling, `kg/m^3` -- is never padded +/// with zeros. #include #include @@ -108,10 +113,11 @@ struct TraceRenderOptions /// rounds the rest in `mode` at the unit's declared decimals and marks /// each with `ApproximationMarker`. Whatever the style, a number typed /// rather than computed, and either side of a comparison a line states, - /// are shown exact (`NumberStyle::exact_only`), and a value in a unit - /// nobody declared is never padded; where its default 3 places would - /// round a value other than zero to `≈0`, they are extended to its first - /// significant digit, up to 18 (`checked_shown_text`). A value a formula + /// are shown exact (`NumberStyle::exact_only`), and a value in the + /// coherent unit -- followed by that unit's spelling, `kg/m^3` -- is + /// never padded; where its default 3 places would round a value other + /// than zero to `≈0`, they are extended to its first significant digit, + /// up to 18 (`checked_shown_text`). A value a formula /// rounded itself -- `rounded`, `rounded_sqrt`, `rounded_ln`, /// `rounded_log10`, `rounded_exp`, `rounded_output` -- is the step's exact /// value, a decimal, so it reads without `≈` in every style, its mode in @@ -580,25 +586,185 @@ namespace detail : std::to_string(recorded.lookupKey)); } + /// A value no line can spell, and why: `(not shown: )`. The one + /// spelling of it, for a value its unit cannot show and for a value its + /// style cannot spell in that unit alike. + [[nodiscard]] inline std::string not_shown_text(ArithmeticError whyNot) + { + return "(not shown: " + std::string { describe(whyNot) } + ")"; + } + + /// The coherent unit of @p dimension, spelt from its base units: + /// `m/s`, `kg/m^3`, `kg/(m s^2)`, `m^(1/2)`; empty for a dimensionless + /// one. Written after every dimensioned value whose unit has no symbol, so + /// that a slope in metres per second does not read as a pure number. + /// + /// A named base dimension is spelt by its name -- the name is also the + /// symbol of its coherent unit -- ahead of the SI units on its side of the + /// slash, in the dimension's own order: `EUR`, `EUR s^2/(m^2 kg)` for euros + /// per joule, `1/JPY`, `EUR/JPY`, `EUR^(1/2)`. First, because a tariff is + /// read as money per energy, not as seconds squared of money per metre. + /// Each name goes through `escaped_author_text`: `base_dimension()` admits + /// only letters and digits, but a hand-filled `namedBases` can hold + /// anything. + [[nodiscard]] inline std::string coherent_unit_text(Dimension dimension) + { + struct BaseUnit + { + std::string_view symbol; + Exponent exponent; + }; + std::array const bases { BaseUnit { "m", dimension.length }, BaseUnit { "kg", dimension.mass }, + BaseUnit { "s", dimension.time }, BaseUnit { "A", dimension.current }, + BaseUnit { "K", dimension.temperature }, BaseUnit { "mol", dimension.amount }, + BaseUnit { "cd", dimension.luminosity } }; + auto const unitPower = [](std::string_view symbolText, std::int32_t numeratorPart, std::int32_t denominatorPart) { + std::string factorText { symbolText }; + if (denominatorPart != 1) + factorText += "^(" + std::to_string(numeratorPart) + "/" + std::to_string(denominatorPart) + ")"; + else if (numeratorPart != 1) + factorText += "^" + std::to_string(numeratorPart); + return factorText; + }; + std::string above; + std::string below; + std::size_t belowCount = 0; + auto const place = [&](std::string_view symbolText, Exponent baseExponent) { + if (baseExponent.numerator > 0) + above += (above.empty() ? "" : " ") + + unitPower(symbolText, baseExponent.numerator, baseExponent.denominator); + else if (baseExponent.numerator < 0) + { + below += (below.empty() ? "" : " ") + + unitPower(symbolText, -baseExponent.numerator, baseExponent.denominator); + ++belowCount; + } + }; + for (std::size_t slot = 0; named_base_in_use(dimension, slot); ++slot) + place(escaped_author_text(view(dimension.namedBases[slot].name)), dimension.namedBases[slot].exponent); + for (BaseUnit const& base: bases) + place(base.symbol, base.exponent); + if (below.empty()) + return above; + return (above.empty() ? std::string { "1" } : above) + "/" + (belowCount > 1 ? "(" + below + ")" : below); + } + + /// Whether a value of @p dimension in @p declared is shown in the coherent + /// unit, spelt by `coherent_unit_text`, rather than in @p declared: when + /// @p declared has no symbol and @p dimension is not dimensionless. A + /// unit with no symbol cannot say what scale its number is on, so the + /// number is moved into the one scale its spelling names. The one rule + /// for every place a number is written with its unit: a step's value, a + /// squared deviation, a conformity row, a derivation's header, and a + /// bound a table, a curve or a permitted set declared + /// (`shown_bound_text`), so that every number on a line is in the unit + /// written after it. + [[nodiscard]] inline bool spells_coherent_unit(Unit const& declared, Dimension dimension) + { + return view(declared.symbolText).empty() && !(dimension == dim::Scalar); + } + + /// The unit a value of @p dimension declared in @p declared is shown in: + /// the coherent unit where `spells_coherent_unit` says so, @p declared + /// otherwise. + [[nodiscard]] inline Unit shown_unit_of(Unit const& declared, Dimension dimension) + { + return spells_coherent_unit(declared, dimension) ? coherent(dimension) : declared; + } + + /// The text written after a value shown in `shown_unit_of(@p declared, + /// @p dimension)`: the coherent unit's spelling, the declared unit's + /// escaped symbol, or nothing for a dimensionless value in a unit with + /// no symbol. + [[nodiscard]] inline std::string shown_unit_text(Unit const& declared, Dimension dimension) + { + return spells_coherent_unit(declared, dimension) ? coherent_unit_text(dimension) : unit_symbol_text(declared); + } + + /// A bound declared in @p declaredIn as a numerator/denominator pair -- a + /// band's, a curve's row or a permitted value -- spelled exact + /// (`declared_number_text`) in the unit a value declared in @p declaredIn + /// is shown in (`shown_unit_of`), without that unit's text: the caller + /// writes `shown_unit_text(declaredIn, declaredIn.dimension)` after the + /// bounds it lists. A bound of a unit with no symbol is moved into the + /// coherent unit, as the value it is compared with is, so that no number + /// on the line is in a scale the line does not name. + /// + /// Only that move can fail, and the bound then reads `(not shown: ...)` + /// rather than as a number in the wrong scale. A 64-bit pair times a + /// well-formed unit's 64-bit magnitude always fits a `Rational`, so the + /// move fails only for a pair that names no rational, a zero denominator; + /// for a malformed unit, one whose magnitude is zero (`DomainError`) or + /// whose magnitude or offset has a zero denominator (`DivisionByZero`); + /// and for a unit with an offset, whose sum can overflow: a bound of + /// 1/(2^63 - 1) in a unit of magnitude 1/(2^63 - 25) and offset + /// 1/(2^63 - 165) does. A bound of a unit with a symbol is never + /// converted, and never fails. + [[nodiscard]] inline std::string shown_bound_text(std::int64_t declaredNumerator, + std::int64_t declaredDenominator, + Unit const& declaredIn, + NumberStyle numberStyle) + { + if (!spells_coherent_unit(declaredIn, declaredIn.dimension)) + return declared_number_text(declaredNumerator, declaredDenominator, declaredIn, numberStyle); + std::expected const declared = Rational::make(declaredNumerator, declaredDenominator); + if (!declared) + return not_shown_text(declared.error()); + Unit const coherentUnit = coherent(declaredIn.dimension); + std::expected const inCoherent = checked_convert(*declared, declaredIn, coherentUnit); + if (!inCoherent) + return not_shown_text(inCoherent.error()); + return styled_number_text(*inCoherent, numberStyle.exact_only(), coherentUnit); + } + + /// Whether two bounds declared as numerator/denominator pairs are one + /// number: compared as reduced rationals, so a row typed `14/4` is the row + /// at `7/2`, and as the raw pairs only where a pair names no rational. Never + /// by their spelled text, which `shown_bound_text` can make the same for two + /// different bounds -- two pairs with a zero denominator, `1/0` and `2/0`, + /// both read `(not shown: ...)`. + [[nodiscard]] inline bool same_declared_bound(std::int64_t firstNumerator, + std::int64_t firstDenominator, + std::int64_t secondNumerator, + std::int64_t secondDenominator) + { + std::expected const firstBound = Rational::make(firstNumerator, firstDenominator); + std::expected const secondBound = Rational::make(secondNumerator, secondDenominator); + if (firstBound.has_value() && secondBound.has_value()) + return *firstBound == *secondBound; + return firstNumerator == secondNumerator && firstDenominator == secondDenominator; + } + /// A half-open interval a lookup step reports about -- a selected band, /// or the extent a whole band table covers: `211/100 to under 307/10 mm`. /// - /// Delegates to `render.hpp`'s `band_text`, which is **the** spelling of a - /// half-open interval in this library, so that a derivation and the - /// formula it derives cannot name one band two ways. That ruling, and the - /// published defect that bought it, are in `render.hpp`'s file comment. + /// Written by `render.hpp`'s `half_open_text`, the words of `band_text`, + /// which is **the** spelling of a half-open interval in this library, so + /// that a derivation and the formula it derives cannot name one band two + /// ways. That ruling, and the published defect that bought it, are in + /// `render.hpp`'s file comment. The bounds are in @p keyUnit, and read as + /// `shown_bound_text` shows them. [[nodiscard]] inline std::string half_open_range_text(LookupRange const& lookupRange, - std::string_view keySymbol, Unit const& keyUnit, NumberStyle numberStyle) { - return band_text(Band { lookupRange.lowNumerator, - lookupRange.lowDenominator, - lookupRange.highNumerator, - lookupRange.highDenominator }, - keySymbol, - keyUnit, - numberStyle); + return half_open_text( + shown_bound_text(lookupRange.lowNumerator, lookupRange.lowDenominator, keyUnit, numberStyle), + shown_bound_text(lookupRange.highNumerator, lookupRange.highDenominator, keyUnit, numberStyle), + shown_unit_text(keyUnit, keyUnit.dimension)); + } + + /// A band a lookup selected, as `half_open_range_text` writes an interval. + [[nodiscard]] inline std::string selected_band_text(Band const& selectedBand, + Unit const& keyUnit, + NumberStyle numberStyle) + { + return half_open_range_text(LookupRange { selectedBand.lowNumerator, + selectedBand.lowDenominator, + selectedBand.highNumerator, + selectedBand.highDenominator }, + keyUnit, + numberStyle); } /// A **closed** range an interpolating curve runs over: `209/10 to 293/10 mm`. @@ -616,16 +782,16 @@ namespace detail /// The bounds are reduced through `declared_number_text`, the same helper /// every other declared bound in this library is printed with, so a curve /// whose first row was typed `1474/200` reads `737/100` here exactly as it - /// does in `render()`. + /// does in `render()` -- in @p keyUnit's own scale, or in the coherent unit + /// for a unit with no symbol (`shown_bound_text`). [[nodiscard]] inline std::string closed_range_text(LookupRange const& lookupRange, - std::string_view keySymbol, Unit const& keyUnit, NumberStyle numberStyle) { return number_with_unit( - declared_number_text(lookupRange.lowNumerator, lookupRange.lowDenominator, keyUnit, numberStyle) + " to " - + declared_number_text(lookupRange.highNumerator, lookupRange.highDenominator, keyUnit, numberStyle), - keySymbol); + shown_bound_text(lookupRange.lowNumerator, lookupRange.lowDenominator, keyUnit, numberStyle) + " to " + + shown_bound_text(lookupRange.highNumerator, lookupRange.highDenominator, keyUnit, numberStyle), + shown_unit_text(keyUnit, keyUnit.dimension)); } /// The two rows an interpolating answer came from: `between 331/100 and @@ -646,17 +812,21 @@ namespace detail /// is why it needs neither `band_text`'s `to under` nor /// `closed_range_text`'s `to`. The numbers go through /// `declared_number_text` like every other declared bound, so a row typed - /// `14/4` reads `7/2` here exactly as it does in `render()`. + /// `14/4` reads `7/2` here exactly as it does in `render()`, and are shown + /// as `shown_bound_text` shows a bound. [[nodiscard]] inline std::string segment_text(Segment const& lookupSegment, - std::string_view keySymbol, Unit const& keyUnit, NumberStyle numberStyle) { + std::string const keySymbol = shown_unit_text(keyUnit, keyUnit.dimension); std::string const lowText = - declared_number_text(lookupSegment.low.numerator, lookupSegment.low.denominator, keyUnit, numberStyle); + shown_bound_text(lookupSegment.low.numerator, lookupSegment.low.denominator, keyUnit, numberStyle); std::string const highText = - declared_number_text(lookupSegment.high.numerator, lookupSegment.high.denominator, keyUnit, numberStyle); - if (lowText == highText) + shown_bound_text(lookupSegment.high.numerator, lookupSegment.high.denominator, keyUnit, numberStyle); + if (same_declared_bound(lookupSegment.low.numerator, + lookupSegment.low.denominator, + lookupSegment.high.numerator, + lookupSegment.high.denominator)) return "on the row at " + number_with_unit(lowText, keySymbol); return "between " + number_with_unit(lowText + " and " + highText, keySymbol); } @@ -699,42 +869,46 @@ namespace detail /// genuinely different questions: a value in none of a table's bands, a /// key in none of its rows, a value off the ends of a curve. /// - /// @p keySymbol is `recorded.sourceUnit`'s symbol, escaped; the table's - /// bounds are numbers in that unit. + /// The table's bounds are numbers in `recorded.sourceUnit`, shown as + /// `shown_bound_text` shows them. [[nodiscard]] inline std::string lookup_miss_text(Trace const& trace, std::size_t stepIndex, Step const& recorded, - std::string_view keySymbol, NumberStyle numberStyle) { if (recorded.kind == StepKind::ExactLookup) return "no row has this key"; - // The count and every size the table does declare, so that a reader - // sees the hole the count fell in: `no row for n = 7 (declared: 3, 4, - // 5, 6, 8)`. + // The count, all of its 128 bits, and every size the table does + // declare, so that a reader sees the hole the count fell in: `no row + // for n = 7 (declared: 3, 4, 5, 6, 8)`. if (recorded.kind == StepKind::SampleSizeLookup) - return "no row for n = " + std::to_string(recorded.lookupKey) + declared_sizes_text(trace, stepIndex); + { + DecimalSpelling const countDigits = u128_decimal(UInt128 { recorded.lookupKeyHigh, recorded.lookupKey }); + return "no row for n = " + std::string { countDigits.characters, static_cast(countDigits.length) } + + declared_sizes_text(trace, stepIndex); + } if (!recorded.coveredRange.has_value()) return recorded.kind == StepKind::BandedLookup ? "the table declares no bands" : "the curve declares no rows"; Unit const& keyUnit = recorded.sourceUnit; if (recorded.kind == StepKind::BandedLookup) - return "in no band; the bands cover " - + half_open_range_text(*recorded.coveredRange, keySymbol, keyUnit, numberStyle); + return "in no band; the bands cover " + half_open_range_text(*recorded.coveredRange, keyUnit, numberStyle); // A curve with exactly one row covers that one key and nothing else, // and "runs 15/2 to 15/2 mm" would describe it as a range it is not. // `at ` is the spelling `render()` gives a breakpoint, for the // same reason: a row is a point. - std::string const lowText = declared_number_text( + std::string const lowText = shown_bound_text( recorded.coveredRange->lowNumerator, recorded.coveredRange->lowDenominator, keyUnit, numberStyle); - std::string const highText = declared_number_text( - recorded.coveredRange->highNumerator, recorded.coveredRange->highDenominator, keyUnit, numberStyle); - if (lowText == highText) - return "outside the curve, whose only row is at " + number_with_unit(lowText, keySymbol); - return "outside the curve, which runs " + closed_range_text(*recorded.coveredRange, keySymbol, keyUnit, numberStyle); + if (same_declared_bound(recorded.coveredRange->lowNumerator, + recorded.coveredRange->lowDenominator, + recorded.coveredRange->highNumerator, + recorded.coveredRange->highDenominator)) + return "outside the curve, whose only row is at " + + number_with_unit(lowText, shown_unit_text(keyUnit, keyUnit.dimension)); + return "outside the curve, which runs " + closed_range_text(*recorded.coveredRange, keyUnit, numberStyle); } /// A lookup step's trailing clause: which row it selected, or -- when it @@ -767,7 +941,6 @@ namespace detail Step const& recorded, NumberStyle numberStyle) { - std::string const keySymbol = unit_symbol_text(recorded.sourceUnit); switch (recorded.lookupFailure) { case LookupFailure::None: @@ -785,15 +958,19 @@ namespace detail // A critical value's row is its size, and the count is that // size -- the one thing the line's `critical(#1)` does not say. if (recorded.kind == StepKind::SampleSizeLookup && recorded.value.has_value() && !recorded.operands.empty()) - return " [critical value at n = " + std::to_string(recorded.lookupKey) + "]"; + { + DecimalSpelling const countDigits = + u128_decimal(UInt128 { recorded.lookupKeyHigh, recorded.lookupKey }); + return " [critical value at n = " + + std::string { countDigits.characters, static_cast(countDigits.length) } + "]"; + } if (recorded.selectedBand.has_value()) - return " [" + band_text(*recorded.selectedBand, keySymbol, recorded.sourceUnit, numberStyle) + "]"; + return " [" + selected_band_text(*recorded.selectedBand, recorded.sourceUnit, numberStyle) + "]"; if (recorded.selectedSegment.has_value()) - return " [" + segment_text(*recorded.selectedSegment, keySymbol, recorded.sourceUnit, numberStyle) - + "]"; + return " [" + segment_text(*recorded.selectedSegment, recorded.sourceUnit, numberStyle) + "]"; return {}; case LookupFailure::Missed: - return " [" + lookup_miss_text(trace, stepIndex, recorded, keySymbol, numberStyle) + "]"; + return " [" + lookup_miss_text(trace, stepIndex, recorded, numberStyle) + "]"; case LookupFailure::Computation: return " [the interpolation itself overflowed, not anything below it]"; case LookupFailure::Conversion: @@ -1473,25 +1650,21 @@ namespace detail + variant_narrowing_clause(recorded) + "]"; } - /// A value no line can spell, and why: `(not shown: )`. The one - /// spelling of it, for a value its unit cannot show and for a value its - /// style cannot spell in that unit alike. - [[nodiscard]] inline std::string not_shown_text(ArithmeticError whyNot) - { - return "(not shown: " + std::string { describe(whyNot) } + ")"; - } - - /// @p storedValue -- a step's own, or one element of a series step's -- converted - /// from the coherent unit of @p recorded's dimension into the unit the - /// step was declared in, with that unit's symbol, or `(not measured)` - /// (`NotMeasuredText`) when it is empty. Shared by `step_value_text` and - /// `series_step_line`, so that an element of a series reads exactly as a - /// single value of the same quantity does. - /// - /// The number is spelled in @p numberStyle (`checked_shown_text`: never - /// padded in a unit nobody declared); a style that cannot spell it in the - /// step's unit is reported as the conversion's failure is, `(not shown: - /// ...)`. The fraction style never fails. + /// @p storedValue -- a step's own, or one element of a series step's -- + /// converted from the coherent unit of @p recorded's dimension into the + /// step's unit (`Step::unit`), with that unit's symbol, or `(not + /// measured)` (`NotMeasuredText`) when it is empty. Shared by + /// `step_value_text` and `series_step_line`, so that an element of a + /// series reads exactly as a single value of the same quantity does. + /// + /// A dimensioned value whose unit has no symbol is shown in the coherent + /// unit instead, followed by that unit's spelling (`coherent_unit_text`), + /// so that no dimensioned value prints as a bare number + /// (`shown_unit_of`). The number is spelled in @p numberStyle + /// (`checked_shown_text`: a coherent value is never padded); a style + /// that cannot spell it in the shown unit is reported as the + /// conversion's failure is, `(not shown: ...)`, with no unit after it. + /// The fraction style never fails. [[nodiscard]] inline std::string value_in_declared_unit(Step const& recorded, std::optional const& storedValue, NumberStyle numberStyle) @@ -1499,8 +1672,15 @@ namespace detail if (!storedValue.has_value()) return std::string { NotMeasuredText }; + // A unit with no symbol cannot say what scale its number is on. A + // dimensioned value is then shown in the coherent unit and followed by + // that unit's spelling (`shown_unit_of`), so that no dimensioned + // value prints as a bare number and no number is shown in a scale its + // line does not name. A dimensionless value is a bare number either + // way. + Unit const shownUnit = shown_unit_of(recorded.unit, recorded.dimension); std::expected const shown = - checked_convert(*storedValue, coherent(recorded.dimension), recorded.unit); + checked_convert(*storedValue, coherent(recorded.dimension), shownUnit); // Unreachable for a `Step` the recorder built -- it records a unit of // the step's own dimension -- but a `Step` is a public aggregate and a // caller may fill one in by hand. Refusing to print is the only @@ -1508,12 +1688,12 @@ namespace detail // claims it is not in. if (!shown) return not_shown_text(shown.error()); - std::expected const spelled = checked_shown_text(*shown, numberStyle, recorded.unit); + std::expected const spelled = checked_shown_text(*shown, numberStyle, shownUnit); if (!spelled) return not_shown_text(spelled.error()); std::string valueText { spelled->view() }; - std::string const unitSymbol = unit_symbol_text(recorded.unit); + std::string const unitSymbol = shown_unit_text(recorded.unit, recorded.dimension); if (!unitSymbol.empty()) valueText += " " + unitSymbol; return valueText; @@ -1522,10 +1702,11 @@ namespace detail /// What a step produced, as a person should read it. /// /// The value is stored in the coherent unit of the step's dimension; - /// this converts it back into the unit the step was declared in and - /// appends that unit's symbol, so an input entered as 180 l reads - /// `180 l`. A step that failed shows why, and one with no value at all - /// says so -- absence is not an error and must not be rendered as one. + /// this converts it back into the unit the step is shown in and appends + /// that unit's text (`value_in_declared_unit`), so an input entered as + /// 180 l reads `180 l`. A step that failed shows why, and one with no + /// value at all says so -- absence is not an error and must not be + /// rendered as one. [[nodiscard]] inline std::string step_value_text(Step const& recorded, NumberStyle numberStyle) { if (recorded.error.has_value()) @@ -1732,12 +1913,11 @@ namespace detail Step observationShape {}; observationShape.dimension = recorded.sourceUnit.dimension; observationShape.unit = recorded.sourceUnit; - std::string const keySymbol = unit_symbol_text(recorded.sourceUnit); NumberStyle const comparedStyle = numberStyle.exact_only(); return positionText + " [" + value_in_declared_unit(observationShape, recorded.domainElements[failedAt], comparedStyle) + " in no class; the classes cover " - + half_open_range_text(*recorded.coveredRange, keySymbol, recorded.sourceUnit, comparedStyle) + "]"; + + half_open_range_text(*recorded.coveredRange, recorded.sourceUnit, comparedStyle) + "]"; } /// A series step's line, without its number: the expression, an `=`, and @@ -1886,15 +2066,14 @@ namespace detail /// nothing was located: a failed or absent curve or point. [[nodiscard]] inline std::string curve_interpolation_suffix(Step const& recorded, NumberStyle numberStyle) { - std::string const pointSymbol = unit_symbol_text(recorded.sourceUnit); // The points the value lay between, or the ends it lay outside, are // one side of the comparison the clause states: `declared_number_text` // spells them in `exact_only()`. if (recorded.selectedSegment.has_value()) - return " [" + segment_text(*recorded.selectedSegment, pointSymbol, recorded.sourceUnit, numberStyle) + "]"; + return " [" + segment_text(*recorded.selectedSegment, recorded.sourceUnit, numberStyle) + "]"; if (recorded.coveredRange.has_value()) return " [outside the curve, which runs " - + closed_range_text(*recorded.coveredRange, pointSymbol, recorded.sourceUnit, numberStyle) + "]"; + + closed_range_text(*recorded.coveredRange, recorded.sourceUnit, numberStyle) + "]"; return {}; } @@ -1968,19 +2147,20 @@ namespace detail /// the one it snapped to, named after `nearer`, is one of them: it is /// spelled in `numberStyle.exact_only()` too, so that it reads exactly as /// the neighbour it names -- and as the step's own value, a typed number - /// (`states_typed_value`), reads before the bracket. + /// (`states_typed_value`), reads before the bracket. All of them are in + /// the unit the step's value is shown in (`shown_bound_text`), so a set + /// declared in a unit with no symbol reads in the coherent unit throughout. [[nodiscard]] inline std::string snap_suffix(Step const& recorded, NumberStyle numberStyle) { - std::string const keySymbol = unit_symbol_text(recorded.unit); Unit const& keyUnit = recorded.unit; + std::string const keySymbol = shown_unit_text(keyUnit, keyUnit.dimension); if (recorded.selectedSegment.has_value()) { Segment const& neighbours = *recorded.selectedSegment; std::string const lowText = number_with_unit( - declared_number_text(neighbours.low.numerator, neighbours.low.denominator, keyUnit, numberStyle), keySymbol); + shown_bound_text(neighbours.low.numerator, neighbours.low.denominator, keyUnit, numberStyle), keySymbol); std::string const highText = number_with_unit( - declared_number_text(neighbours.high.numerator, neighbours.high.denominator, keyUnit, numberStyle), - keySymbol); + shown_bound_text(neighbours.high.numerator, neighbours.high.denominator, keyUnit, numberStyle), keySymbol); if (neighbours.low == neighbours.high) return " [on " + lowText + "]"; if (recorded.tieBroken) @@ -1995,11 +2175,10 @@ namespace detail LookupRange const& covered = *recorded.coveredRange; return " [outside the permitted set, " + number_with_unit( - declared_number_text(covered.lowNumerator, covered.lowDenominator, keyUnit, numberStyle), keySymbol) + shown_bound_text(covered.lowNumerator, covered.lowDenominator, keyUnit, numberStyle), keySymbol) + " to " + number_with_unit( - declared_number_text(covered.highNumerator, covered.highDenominator, keyUnit, numberStyle), - keySymbol) + shown_bound_text(covered.highNumerator, covered.highDenominator, keyUnit, numberStyle), keySymbol) + "]"; } return {}; @@ -2015,8 +2194,9 @@ namespace detail } /// One element's outcome in a conformity step, counted from one, with - /// the value judged in the check's unit and the row it was judged against - /// when the trace kept them: `2 satisfied, 36 % (from 30 to 40 %)`, `2 + /// the value judged and the row it was judged against when the trace kept + /// them, both in the unit the step's values are shown in + /// (`shown_unit_of`): `2 satisfied, 36 % (from 30 to 40 %)`, `2 /// violated, 71 % (at least 60 %): reject the specimen`, `2 not checked /// (...)`, or `2 invalid, 71 % (from 80 to 70 %): `. An element not measured states no value, and neither does @@ -2043,9 +2223,44 @@ namespace detail return ordinal + " unknown outcome" + rowClause; } + /// @p limitRow, whose limits are numbers in @p recorded's unit, as the + /// range it permits (`limit_row_text`), in the unit @p recorded's value + /// is shown in (`shown_unit_of`), so that a value and the row it was + /// judged against are never shown in two scales. A limit the shown unit + /// cannot hold is reported, `(not shown: ...)`, never restated. + [[nodiscard]] inline std::string conformity_row_text(ShownStep const& recorded, + LimitRow const& limitRow, + NumberStyle numberStyle) + { + if (!spells_coherent_unit(recorded.unit, recorded.dimension)) + return limit_row_text(limitRow, unit_symbol_text(recorded.unit), recorded.unit, numberStyle); + Unit const shownUnit = shown_unit_of(recorded.unit, recorded.dimension); + auto const inShownUnit = [&](Limit const& side) -> std::expected { + std::optional const sideValue = side.value(); + if (!sideValue.has_value()) + return side; + std::expected const sideInShownUnit = + checked_convert(*sideValue, recorded.unit, shownUnit); + if (!sideInShownUnit) + return std::unexpected { sideInShownUnit.error() }; + return formula::limit(*sideInShownUnit); + }; + std::expected const lowerShown = inShownUnit(limitRow.lower); + if (!lowerShown) + return not_shown_text(lowerShown.error()); + std::expected const upperShown = inShownUnit(limitRow.upper); + if (!upperShown) + return not_shown_text(upperShown.error()); + return limit_row_text(LimitRow { .lower = *lowerShown, .upper = *upperShown }, + shown_unit_text(recorded.unit, recorded.dimension), + shownUnit, + numberStyle); + } + /// A conformity step's line, without its number: `conform(#1)` and every - /// element's outcome in one bracket, each with the value judged, in the - /// check's unit, and the row it was judged against -- `[1 satisfied, 36 % + /// element's outcome in one bracket, each with the value judged and the + /// row it was judged against, both in the unit the step's values are + /// shown in (`shown_unit_of`) -- `[1 satisfied, 36 % /// (from 30 to 40 %); 2 violated, 55 % (from 50 to 60 %): reject the /// specimen; ...]` -- as many as @p budget allows, one /// unit each, as a series step's elements are (`series_step_line`), and @@ -2074,9 +2289,7 @@ namespace detail if (at > 0) lineText += "; "; std::string const rowClause = - at < limits.size() - ? " (" + limit_row_text(limits[at], unit_symbol_text(recorded.unit), recorded.unit, comparedStyle) + ")" - : std::string {}; + at < limits.size() ? " (" + conformity_row_text(recorded, limits[at], comparedStyle) + ")" : std::string {}; std::string const valueClause = at < recorded.elements.size() && recorded.elements[at].has_value() ? ", " + value_in_declared_unit(recorded, recorded.elements[at], comparedStyle) @@ -2204,7 +2417,10 @@ namespace detail /// @p si, a value in the coherent unit of @p recorded's dimension -- or of /// its square, when @p squared -- in @p recorded's unit (or its square), - /// with the unit's symbol: `27/10 g`, `729/100 g2`. Refuses to print, as + /// with the unit's symbol: `27/10 g`, `729/100 g2`. A unit with no symbol + /// is shown as `value_in_declared_unit` shows it, in the coherent unit + /// (`shown_unit_of`), and its square is spelt from the base units: + /// `106/25 K`, `11236/625 K^2`. Refuses to print, as /// `value_in_declared_unit` does, a value its unit cannot show, or one /// @p numberStyle cannot spell in it. A square declares no decimals of its /// own, so a squared value is never padded, as a value in a unit nobody @@ -2217,7 +2433,7 @@ namespace detail { if (!squared) return value_in_declared_unit(recorded, si, numberStyle); - Unit const shownUnit = recorded.unit; + Unit const shownUnit = shown_unit_of(recorded.unit, recorded.dimension); std::expected const magnitude = Rational::make(shownUnit.magnitudeNumerator, shownUnit.magnitudeDenominator); std::expected const magnitudeSquared = @@ -2231,8 +2447,11 @@ namespace detail if (!spelled) return not_shown_text(spelled.error()); std::string valueText { spelled->view() }; - std::string const unitSymbol = unit_symbol_text(shownUnit); - if (!unitSymbol.empty()) + // A unit with a symbol squares as the library writes squares (`g2`); + // the coherent one is spelt from its bases (`K^2`). + if (spells_coherent_unit(recorded.unit, recorded.dimension)) + valueText += " " + coherent_unit_text(recorded.dimension * recorded.dimension); + else if (std::string const unitSymbol = unit_symbol_text(shownUnit); !unitSymbol.empty()) valueText += " " + unitSymbol + "2"; return valueText; } @@ -2470,64 +2689,9 @@ namespace detail std::optional failedInput {}; }; - /// The coherent unit of @p dimension, spelt from its base units: - /// `m/s`, `kg/m^3`, `kg/(m s^2)`, `m^(1/2)`; empty for a dimensionless - /// one. For an opaque output shown in no input's unit, so that a slope in - /// metres per second does not read as a pure number. - /// - /// A named base dimension is spelt by its name -- the name is also the - /// symbol of its coherent unit -- ahead of the SI units on its side of the - /// slash, in the dimension's own order: `EUR`, `EUR s^2/(m^2 kg)` for euros - /// per joule, `1/JPY`, `EUR/JPY`, `EUR^(1/2)`. First, because a tariff is - /// read as money per energy, not as seconds squared of money per metre. - /// Each name goes through `escaped_author_text`: `base_dimension()` admits - /// only letters and digits, but a hand-filled `namedBases` can hold - /// anything. - [[nodiscard]] inline std::string coherent_unit_text(Dimension dimension) - { - struct BaseUnit - { - std::string_view symbol; - Exponent exponent; - }; - std::array const bases { BaseUnit { "m", dimension.length }, BaseUnit { "kg", dimension.mass }, - BaseUnit { "s", dimension.time }, BaseUnit { "A", dimension.current }, - BaseUnit { "K", dimension.temperature }, BaseUnit { "mol", dimension.amount }, - BaseUnit { "cd", dimension.luminosity } }; - auto const unitPower = [](std::string_view symbolText, std::int32_t numeratorPart, std::int32_t denominatorPart) { - std::string factorText { symbolText }; - if (denominatorPart != 1) - factorText += "^(" + std::to_string(numeratorPart) + "/" + std::to_string(denominatorPart) + ")"; - else if (numeratorPart != 1) - factorText += "^" + std::to_string(numeratorPart); - return factorText; - }; - std::string above; - std::string below; - std::size_t belowCount = 0; - auto const place = [&](std::string_view symbolText, Exponent baseExponent) { - if (baseExponent.numerator > 0) - above += (above.empty() ? "" : " ") - + unitPower(symbolText, baseExponent.numerator, baseExponent.denominator); - else if (baseExponent.numerator < 0) - { - below += (below.empty() ? "" : " ") - + unitPower(symbolText, -baseExponent.numerator, baseExponent.denominator); - ++belowCount; - } - }; - for (std::size_t slot = 0; named_base_in_use(dimension, slot); ++slot) - place(escaped_author_text(view(dimension.namedBases[slot].name)), dimension.namedBases[slot].exponent); - for (BaseUnit const& base: bases) - place(base.symbol, base.exponent); - if (below.empty()) - return above; - return (above.empty() ? std::string { "1" } : above) + "/" + (belowCount > 1 ? "(" + below + ")" : below); - } - - /// @p storedValue in @p shownUnit, and -- when that unit has no symbol of - /// its own but a dimension -- followed by the coherent unit's spelling - /// (`coherent_unit_text`), for an opaque output. + /// @p storedValue in @p shownUnit, for an opaque output -- or, when that + /// unit has no symbol but a dimension, in the coherent unit with its + /// spelling, as `value_in_declared_unit` shows every value. [[nodiscard]] inline std::string opaque_value_text(Dimension dimension, Unit shownUnit, std::optional const& storedValue, @@ -2536,10 +2700,7 @@ namespace detail Step outputShape {}; outputShape.dimension = dimension; outputShape.unit = shownUnit; - std::string valueText = value_in_declared_unit(outputShape, storedValue, numberStyle); - if (storedValue.has_value() && view(shownUnit.symbolText).empty() && !(dimension == dim::Scalar)) - valueText += " " + coherent_unit_text(dimension); - return valueText; + return value_in_declared_unit(outputShape, storedValue, numberStyle); } /// What an opaque call's line says of whose failure it carries, bracketed: @@ -3336,30 +3497,48 @@ namespace detail } /// The value of @p shown's block as its header states it: in the unit it - /// was declared in, with that unit's symbol, spelled in @p numberStyle as - /// a trace line spells a value (`checked_shown_text`), or `(not shown: - /// ...)` where the style cannot spell it in that unit; why its - /// calculation failed; or `(no value)`. Exact only when @p typed, the - /// block's value being a number typed rather than computed - /// (`derivation_value_is_typed`), so that the header, the block's root - /// and every line reading the value agree on whether it is typed. That - /// is all they agree on: a computed root states the value in the + /// was declared in, with that unit's symbol -- or, for a dimensioned unit + /// with no symbol, in the coherent unit with its spelling + /// (`shown_unit_of`) -- spelled in @p numberStyle as a trace line spells + /// a value (`checked_shown_text`), or `(not shown: ...)` where the style + /// cannot spell it in that unit or the move into the coherent unit + /// fails; why its calculation failed; or `(no value)`. Exact only + /// when @p typed, the block's value being a number typed rather than + /// computed (`derivation_value_is_typed`), so that the header, the + /// block's root and every line reading the value agree on whether it is + /// typed. That is all they agree on: a computed root states the value in + /// the unit its step is shown in (`Step::unit`), which may be the /// coherent unit, so it may differ from the header in its unit, its /// padding and its decimals, and one may read `≈` where the other does /// not -- a length of `1 u`, in a unit of a third of a metre, is - /// `≈0.333` metres at its root. + /// `≈0.333 m` at a root computed in the coherent unit. [[nodiscard]] inline std::string block_value_text(WorksheetEntry const& shown, NumberStyle numberStyle, bool typed) { if (shown.error.has_value()) return std::string { describe(*shown.error) }; if (!shown.value.has_value()) return "(no value)"; + // The value is held in its declared unit. One with a symbol is shown + // as it is: converting it to itself would pass through the coherent + // unit, which can overflow for a value its own unit holds well, a + // great many kilowatt-hours counted in joules. One with no symbol + // moves into the coherent unit and says so, as a trace line's value + // does (`shown_unit_of`); that move is the one that can fail: for a + // value whose coherent form overflows, and for a malformed unit, one + // whose magnitude is zero (`DomainError`) or whose magnitude or offset + // has a zero denominator (`DivisionByZero`). + Unit const shownUnit = shown_unit_of(shown.unit, shown.unit.dimension); + std::expected const inShownUnit = + spells_coherent_unit(shown.unit, shown.unit.dimension) ? checked_convert(*shown.value, shown.unit, shownUnit) + : std::expected { *shown.value }; + if (!inShownUnit) + return not_shown_text(inShownUnit.error()); std::expected const spelled = - checked_shown_text(*shown.value, typed ? numberStyle.exact_only() : numberStyle, shown.unit); + checked_shown_text(*inShownUnit, typed ? numberStyle.exact_only() : numberStyle, shownUnit); if (!spelled) return not_shown_text(spelled.error()); std::string valueText { spelled->view() }; - std::string const unitSymbol = unit_symbol_text(shown.unit); + std::string const unitSymbol = shown_unit_text(shown.unit, shown.unit.dimension); if (!unitSymbol.empty()) valueText += " " + unitSymbol; return valueText; @@ -3390,18 +3569,22 @@ namespace detail /// /// Each calculated value's block opens with a header, `symbol = definition = /// value` -- the definition as `render` writes it, and the value in the unit -/// the quantity was declared in, or why calculating it failed, or `(no -/// value)` -- followed by its steps, indented and numbered from one within -/// the block, each line as `render_trace` writes it. An overridden value's -/// block is the header alone: `symbol = value, entered by hand in place of -/// definition`. The inputs read follow under a line `inputs`, one indented -/// line each, as `render_trace` writes a variable's step. A computed step -/// states its value in the coherent unit of its dimension, as in -/// `render_trace` (`docs/tracing.md`, "Reading a derivation"), so it may -/// read differently from the header above it: `fridge_kwh = fridge_kw * -/// fridge_h = 24/5 kWh` over `3. #1 * #2 = 17280000`, in joules. Under a -/// rounding style the two may differ in their decimals too, one reading `≈` -/// where the other does not. +/// it is shown in (`detail::shown_unit_of`): the quantity's declared unit, or +/// the coherent unit with its spelling for a dimensioned unit with no symbol +/// -- or why calculating it failed, or `(no value)` -- followed by its +/// steps, indented and numbered from one within the block, each line as +/// `render_trace` writes it. An overridden value's block is the header +/// alone: `symbol = value, entered by hand in place of definition`. The +/// inputs read follow under a line `inputs`, one indented line each, as +/// `render_trace` writes a variable's step. A computed step states its value +/// in the unit its step is shown in (`Step::unit`), as in `render_trace` +/// (`docs/tracing.md`, "Reading a derivation"): a unit borrowed from its +/// operands where that is safe, and otherwise the coherent unit of its +/// dimension, spelt from its base units. So it may read differently from the +/// header above it: `fridge_kwh = fridge_kw * fridge_h = 24/5 kWh` over +/// `3. #1 * #2 = 17280000 m^2 kg/s^2`, a power times a time in joules. Under +/// a rounding style the two may differ in their decimals too, one reading +/// `≈` where the other does not. /// /// **Every number is spelled in @p options.numbers**, fractions unless the /// caller names another style, as `render_trace` spells a trace's: each @@ -3414,7 +3597,7 @@ namespace detail /// there. A typed value is exact wherever the derivation states it: in its /// block's header, on its root's line, and on each line of another block /// that reads it (`WorksheetEntry::readSlots`) -- where it reads as its -/// header does, both in the unit the quantity was declared in. +/// header does, both in the unit the quantity's values are shown in. /// /// **One budget bounds it all.** Every header, step and input line spends /// one unit of @p options.maxSteps, and a step showing a series spends one diff --git a/support/census_report.cpp b/support/census_report.cpp index a2c67601..e3585566 100644 --- a/support/census_report.cpp +++ b/support/census_report.cpp @@ -23,12 +23,12 @@ struct ReportAtExit { using formula::detail::CensusRole; std::println("overflow census: numerator {} bits, denominator {} bits, intermediate {} bits, unsigned {} " - "bits; headroom {} of 63", + "bits; headroom {} of 127", formula_census::bits_used(CensusRole::Numerator), formula_census::bits_used(CensusRole::Denominator), formula_census::bits_used(CensusRole::Intermediate), formula_census::bits_used(CensusRole::Unsigned), - 63 - formula_census::signed_bits_used()); + 127 - formula_census::signed_bits_used()); std::fflush(stdout); } }; diff --git a/support/census_tally.cpp b/support/census_tally.cpp index 4c16754c..46de9068 100644 --- a/support/census_tally.cpp +++ b/support/census_tally.cpp @@ -7,16 +7,14 @@ #include #include -#include #include -#include namespace { /// The largest magnitude of each role seen since the last reset. One /// program, one thread: the census programs evaluate on the main thread only. -std::array largestSeen {}; +std::array largestSeen {}; [[nodiscard]] std::size_t slot(formula::detail::CensusRole role) noexcept { @@ -28,9 +26,10 @@ std::array largestSeen {}; namespace formula::detail { -void census_record(CensusRole role, std::uint64_t magnitudeSeen) noexcept +void census_record(CensusRole role, UInt128 magnitudeSeen) noexcept { - largestSeen[slot(role)] = std::max(largestSeen[slot(role)], magnitudeSeen); + if (largestSeen[slot(role)] < magnitudeSeen) + largestSeen[slot(role)] = magnitudeSeen; } } // namespace formula::detail @@ -40,23 +39,22 @@ namespace formula_census int bits_used(formula::detail::CensusRole role) noexcept { - return static_cast(std::bit_width(largestSeen[slot(role)])); + return largestSeen[slot(role)].bit_width(); } int signed_bits_used() noexcept { using formula::detail::CensusRole; - // IntMin's magnitude, 2^63, is representable as a numerator: it uses - // every bit there is, and no more. + // `Rational::Int`'s minimum, -2^127, uses every bit there is, and no more. return std::min( - 63, + 127, std::max( { bits_used(CensusRole::Numerator), bits_used(CensusRole::Denominator), bits_used(CensusRole::Intermediate) })); } void reset() noexcept { - largestSeen.fill(0); + largestSeen.fill(formula::detail::UInt128 {}); } } // namespace formula_census diff --git a/support/census_tally.hpp b/support/census_tally.hpp index df332997..1a33cc9f 100644 --- a/support/census_tally.hpp +++ b/support/census_tally.hpp @@ -14,11 +14,11 @@ namespace formula_census { /// The bits the largest magnitude of @p role used since the last `reset` -- -/// 0 when none was seen. INT64_MAX uses 63; `rounded_sqrt`'s unsigned -/// intermediates may use 64. +/// 0 when none was seen. The largest `Rational::Int` uses all but its sign +/// bit; `rounded_sqrt`'s unsigned intermediates may use every bit. [[nodiscard]] int bits_used(formula::detail::CensusRole role) noexcept; -/// The largest of the three signed roles' bits: what is left of 63 is the +/// The largest of the three signed roles' bits: what is left of 127 is the /// headroom. [[nodiscard]] int signed_bits_used() noexcept; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4686f05c..6290d5a4 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -25,6 +25,7 @@ add_executable(formula-cpp-tests cross_tu_a.cpp expression_cross_tu_b.cpp checked_int_tests.cpp + int128_tests.cpp citation_tests.cpp document_tests.cpp error_tests.cpp @@ -50,6 +51,7 @@ add_executable(formula-cpp-tests function_tests.cpp trace_tests.cpp trace_render_tests.cpp + trace_shown_unit_tests.cpp rounding_node_tests.cpp rounded_root_tests.cpp transcendental_tests.cpp @@ -276,8 +278,6 @@ endfunction() # failed: ''`), so one literal substring matches on every leg. formula_add_negative_test(rational_from_floating_point "formula: a floating-point value is not an exact rational") -formula_add_negative_test(rational_from_wide_unsigned - "formula: this unsigned type can hold values above Rational's maximum") # These two expect a NAME rather than a message. An invalid exponent is rejected # by calling a deliberately non-`constexpr` sentinel whose name states the @@ -451,8 +451,8 @@ formula_add_negative_test(measured_series_short "this series was given a different number of elements than its length") # A wrong element in measured_series: another quantity's measurement draws the -# series' own message (once, and no overload list); a double or a wide -# unsigned draws Rational's, and the series' message stays silent. +# series' own message (once, and no overload list); a double draws +# Rational's, and the series' message stays silent. formula_add_negative_test(measured_series_element_other_quantity "formula: an element of measured_series is a Measured of that one quantity" EXPECT_COUNT 1 REJECT "no matching" "cannot convert" "could not convert") @@ -462,9 +462,6 @@ formula_add_negative_test(measured_series_element_bool formula_add_negative_test(measured_series_element_double "formula: a floating-point value is not an exact rational" EXPECT_COUNT 1 REJECT "an element of measured_series") -formula_add_negative_test(measured_series_element_wide_unsigned - "formula: this unsigned type can hold values above Rational's maximum" EXPECT_COUNT 1 - REJECT "an element of measured_series") # A series where one value is asked for: checked_evaluate, evaluate and # variant. The REJECTs pin that nothing past the refusal reads the @@ -1142,6 +1139,10 @@ formula_add_negative_test(format_places_out_of_range formula_add_negative_test(format_spec_not_understood "formula_number_format_spec_not_understood" REJECT "formula_number_format_needs_a_rounding_mode" "formula_number_format_places_out_of_range") +formula_add_negative_test(int128_format_spec_not_understood + "formula_int128_format_spec_not_understood" + REJECT "formula_number_format_spec_not_understood" "formula_number_format_needs_a_rounding_mode" + "formula_number_format_places_out_of_range") formula_add_negative_test(band_gap "formula: this band table has a gap or overlap between two adjacent bands" @@ -1187,6 +1188,11 @@ formula_add_negative_test(breakpoint_pair_denominator_from_floating_point "formula: a breakpoint's key is an exact number" EXPECT_COUNT 1 REJECT "no matching" "ambiguous") +# A Band's bounds and a Breakpoint's key are 64-bit pairs, so that they stay +# template arguments; a Rational beyond 64 bits is refused by name, never cut. +formula_add_negative_test(band_bound_out_of_range "formula_band_bound_out_of_range") +formula_add_negative_test(breakpoint_key_out_of_range "formula_breakpoint_key_out_of_range") + formula_add_negative_test(lookup_band_gap "formula: this band table has a gap or overlap between two adjacent bands" REJECT "must be initialized by a constant expression") diff --git a/test/binning_tests.cpp b/test/binning_tests.cpp index f16d771a..559153bb 100644 --- a/test/binning_tests.cpp +++ b/test/binning_tests.cpp @@ -245,25 +245,38 @@ struct SizeInKm: formula::Quantity micrometreClasses { band(0, 1, 1'030'000'000'000'000'000, 1) }; constexpr auto countedInUm = formula::binned(formula::observations); } // namespace TEST_CASE("an observation whose conversion overflows fails at its position, never skipped", "[binning]") { + constexpr formula::Rational::Int Quintillion = 1'000'000'000'000'000'000; // Into the classes' unit: the second observation. - constexpr auto intoKey = sizes_env(rat(103), rat(1'030'000'000'000'000)); + constexpr auto intoKey = sizes_env( + rat(103), formula::Rational { formula::Rational::Int { 1'030'000'000'000'000 } * Quintillion }); STATIC_REQUIRE( formula::checked_evaluate_series(countedInUm, intoKey).error() == formula::SeriesFailure { formula::ArithmeticError::Overflow, 1, formula::FailureSite::InputObservation }); - // Into SI already: 1.03 x 10^17 km is 1.03 x 10^20 m, the third + // Into SI already: 1.03 x 10^36 km is 1.03 x 10^39 m, the third // observation. - constexpr auto intoSi = sizes_env(rat(103), rat(103), rat(103'000'000'000'000'000)); + constexpr auto intoSi = sizes_env( + rat(103), rat(103), formula::Rational { formula::Rational::Int { 1'030'000'000'000'000'000 } * Quintillion }); STATIC_REQUIRE( formula::checked_evaluate_series(countedInUm, intoSi).error() == formula::SeriesFailure { formula::ArithmeticError::Overflow, 2, formula::FailureSite::InputObservation }); + // 1.03 x 10^15 km and 1.03 x 10^17 km, which overflowed 64 bits, convert: + // 1.03 x 10^24 um and 1.03 x 10^26 um, above every class. + constexpr auto aboveInKey = sizes_env(rat(103), rat(1'030'000'000'000'000)); + STATIC_REQUIRE( + formula::checked_evaluate_series(countedInUm, aboveInKey).error() + == formula::SeriesFailure { formula::ArithmeticError::DomainError, 1, formula::FailureSite::InputObservation }); + constexpr auto aboveInSi = sizes_env(rat(103), rat(103), rat(103'000'000'000'000'000)); + STATIC_REQUIRE( + formula::checked_evaluate_series(countedInUm, aboveInSi).error() + == formula::SeriesFailure { formula::ArithmeticError::DomainError, 2, formula::FailureSite::InputObservation }); formula::Trace<> keyTrace {}; (void) formula::checked_evaluate_series(countedInUm, intoKey, formula::RecordingSink<> { keyTrace }); diff --git a/test/calculation_trace_tests.cpp b/test/calculation_trace_tests.cpp index d0a89d66..49496deb 100644 --- a/test/calculation_trace_tests.cpp +++ b/test/calculation_trace_tests.cpp @@ -832,7 +832,7 @@ TEST_CASE("an override between calculated blocks is one line of the budget", "[c std::string const everything = "energy_cost = grid_cost - feed_in_credit = 388/5 EUR\n" " 1. grid_cost = 80 EUR, calculated\n" " 2. feed_in_credit = 12/5 EUR, calculated\n" - " 3. #1 - #2 = 388/5\n" + " 3. #1 - #2 = 388/5 EUR\n" "feed_in_credit = exported * feed_in = 12/5 EUR\n" " 1. exported = 30 kWh, calculated\n" " 2. feed_in = 2/25 EUR/kWh\n" @@ -845,11 +845,11 @@ TEST_CASE("an override between calculated blocks is one line of the budget", "[c "exported = solar - self_used = 30 kWh\n" " 1. solar = 150 kWh\n" " 2. self_used = 120 kWh, calculated\n" - " 3. #1 - #2 = 108000000\n" + " 3. #1 - #2 = 30 kWh\n" "self_used = solar * 4/5 = 120 kWh\n" " 1. solar = 150 kWh\n" " 2. 4/5\n" - " 3. #1 * #2 = 432000000\n" + " 3. #1 * #2 = 120 kWh\n" "inputs\n" " solar = 150 kWh\n" " price = 8/25 EUR/kWh\n" @@ -880,7 +880,7 @@ TEST_CASE("a bill's derivation, in its vocabulary, cut short", "[calculation][wo CHECK(formula::render_derivation(explained, { .maxSteps = 4 }) == "C_bill = S + vat = 190043/2000 EUR\n" " 1. S = 1597/20 EUR, calculated\n" " 2. vat = 30343/2000 EUR, calculated\n" - " 3. #1 + #2 = 190043/2000\n" + " 3. #1 + #2 = 190043/2000 EUR\n" "... 66 further steps not shown\n"); // In full: the inputs close it, the typed-in price saying so. @@ -888,12 +888,13 @@ TEST_CASE("a bill's derivation, in its vocabulary, cut short", "[calculation][wo CHECK(full.find("further step") == std::string::npos); CHECK(full.find("\nvat = S * 19/100 = 30343/2000 EUR\n 1. S = 1597/20 EUR, calculated\n") != std::string::npos); CHECK(full.find("\nfridge_kw = fridge_w = 1/5 kW\n 1. fridge_w = 200 W\n") != std::string::npos); - // A computed step states its value in the coherent unit, as render_trace - // does: 24/5 kWh in joules. + // A computed step states its value as render_trace does: a power times a + // time borrows neither one's unit, so 24/5 kWh reads in the coherent + // unit, joules spelt from the base units. CHECK(full.find("\nfridge_kwh = fridge_kw * fridge_h = 24/5 kWh\n" " 1. fridge_kw = 1/5 kW, calculated\n" " 2. fridge_h = 24 h\n" - " 3. #1 * #2 = 17280000\n") + " 3. #1 * #2 = 17280000 m^2 kg/s^2\n") != std::string::npos); CHECK(full.ends_with("inputs\n" " fridge_w = 200 W\n" @@ -1047,9 +1048,12 @@ TEST_CASE("a derivation pads a header as a trace pads the line that reads its va // The bill with the energy drawn entered by hand, rendered rounded and // padded: each value in a unit that declares decimals is padded to them // -- the euro's two, the kilowatt-hour's three, the tariff's four -- in a - // header and on the line that reads it alike. A computed step, stated in - // the coherent unit nobody declared, is never padded, and the typed 4/5 - // is written exactly, 0.8, in the definition and on its step. + // header and on the line that reads it alike. A computed step that + // borrows its operands' unit -- a difference of euros, of kilowatt-hours + // -- is padded as that unit is; one in the coherent unit nobody declared, + // a product of an energy and a tariff, is a bare number here, never + // padded. The typed 4/5 is written exactly, 0.8, in the definition and on + // its step. using namespace household; auto sheet = formula::worksheet(bill, bill_environment(billValues)); sheet.set(formula::entered(formula::Measured { rat(250) })); @@ -1060,7 +1064,7 @@ TEST_CASE("a derivation pads a header as a trace pads the line that reads its va == "energy_cost = grid_cost - feed_in_credit = 77.60 EUR\n" " 1. grid_cost = 80.00 EUR, calculated\n" " 2. feed_in_credit = 2.40 EUR, calculated\n" - " 3. #1 - #2 = 77.6\n" + " 3. #1 - #2 = 77.60 EUR\n" "feed_in_credit = exported * feed_in = 2.40 EUR\n" " 1. exported = 30.000 kWh, calculated\n" " 2. feed_in = 0.0800 EUR/kWh\n" @@ -1073,11 +1077,11 @@ TEST_CASE("a derivation pads a header as a trace pads the line that reads its va "exported = solar - self_used = 30.000 kWh\n" " 1. solar = 150.000 kWh\n" " 2. self_used = 120.000 kWh, calculated\n" - " 3. #1 - #2 = 108000000\n" + " 3. #1 - #2 = 30.000 kWh\n" "self_used = solar * 0.8 = 120.000 kWh\n" " 1. solar = 150.000 kWh\n" " 2. 0.8\n" - " 3. #1 * #2 = 432000000\n" + " 3. #1 * #2 = 120.000 kWh\n" "inputs\n" " solar = 150.000 kWh\n" " price = 0.3200 EUR/kWh\n" @@ -1206,8 +1210,9 @@ TEST_CASE("a derivation's header says a value is not shown where its style canno { // A length in a unit declaring 19 decimals, more than a rounding or a // padding can take: under a style that pads or rounds, its header and - // the line reading it say it is not shown, while its root, in metres, - // spells it. In fractions every value is shown. + // the line reading it say it is not shown, while its root, a width in + // millimetres scaled by a pure number and so in millimetres too, spells + // it. In fractions every value is shown. auto sheet = formula::worksheet(overPrecise, formula::environment(formula::Measured { rat(3) })); auto const explained = formula::explain_worksheet(sheet); std::string const fractions = formula::render_derivation(explained, { .maxSteps = 20 }); @@ -1220,8 +1225,14 @@ TEST_CASE("a derivation's header says a value is not shown where its style canno std::string const styled = formula::render_derivation(explained, { .maxSteps = 20, .numbers = style }); CHECK(styled.find("\nl_u = b * 2 = (not shown: overflow in exact arithmetic)\n") != std::string::npos); CHECK(styled.find("\n 1. l_u = (not shown: overflow in exact arithmetic), calculated\n") != std::string::npos); - CHECK(styled.find("\n 3. #1 * #2 = 0.006\n") != std::string::npos); } + std::string const padded = formula::render_derivation( + explained, { .maxSteps = 20, .numbers = formula::NumberStyle::exact_decimal(formula::DecimalPadding::Padded) }); + CHECK(padded.find("\n 3. #1 * #2 = 6.0 mm\n") != std::string::npos); + std::string const approximated = formula::render_derivation( + explained, + { .maxSteps = 20, .numbers = formula::NumberStyle::approximate_decimal(formula::RoundingMode::HalfEven) }); + CHECK(approximated.find("\n 3. #1 * #2 = 6 mm\n") != std::string::npos); } TEST_CASE("a derivation's computed price per energy shows its first significant digit, not a zero", @@ -1239,7 +1250,7 @@ TEST_CASE("a derivation's computed price per energy shows its first significant CHECK(formula::render_derivation(explained, { .maxSteps = 20 }) == "price = grid_cost / net_draw = 8/25 EUR/kWh\n" " 1. grid_cost = 80 EUR\n" " 2. net_draw = 250 kWh\n" - " 3. #1 / #2 = 1/11250000\n" + " 3. #1 / #2 = 1/11250000 s^2/(m^2 kg)\n" "inputs\n" " grid_cost = 80 EUR\n" " net_draw = 250 kWh\n"); @@ -1250,7 +1261,7 @@ TEST_CASE("a derivation's computed price per energy shows its first significant " 1. grid_cost = 80 EUR\n" " 2. net_draw = 250 kWh\n" " 3. #1 / #2 = \xe2\x89\x88" - "0.00000009\n" + "0.00000009 s^2/(m^2 kg)\n" "inputs\n" " grid_cost = 80 EUR\n" " net_draw = 250 kWh\n"); @@ -1309,25 +1320,27 @@ TEST_CASE("money of its own is calculated, derived, documented and spelled in it formula::NumberStyle const decimals = formula::NumberStyle::exact_decimal(); // The derivation in exact decimals, each value in its quantity's unit. A - // computed step is in the coherent unit, the rate's in euros per joule, - // and neither it nor the rate, 3401/9300 EUR/kWh, has an exact decimal. + // sum of euros reads in euros; the rate, an amount over an energy, + // borrows neither unit and reads in the coherent euros per joule, and + // neither it nor the rate's header, 3401/9300 EUR/kWh, has an exact + // decimal. auto const explained = formula::explain_worksheet(sheet); CHECK(explained.entries.front().unit == MoneyEuroPerKwh); CHECK(formula::render_derivation(explained, { .maxSteps = 30, .numbers = decimals }) == "rate = total / draw = 3401/9300 EUR/kWh\n" " 1. total = 102.03 EUR, calculated\n" " 2. draw = 279 kWh\n" - " 3. #1 / #2 = 3401/33480000000\n" + " 3. #1 / #2 = 3401/33480000000 EUR s^2/(m^2 kg)\n" "total = charge + fee + 0.25 EUR = 102.03 EUR\n" " 1. charge = 89.28 EUR, calculated\n" " 2. fee = 12.5 EUR\n" - " 3. #1 + #2 = 101.78\n" + " 3. #1 + #2 = 101.78 EUR\n" " 4. 0.25 EUR\n" - " 5. #3 + #4 = 102.03\n" + " 5. #3 + #4 = 102.03 EUR\n" "charge = draw * tariff = 89.28 EUR\n" " 1. draw = 279 kWh\n" " 2. tariff = 0.32 EUR/kWh\n" - " 3. #1 * #2 = 89.28\n" + " 3. #1 * #2 = 89.28 EUR\n" "inputs\n" " draw = 279 kWh\n" " tariff = 0.32 EUR/kWh\n" diff --git a/test/checked_int_tests.cpp b/test/checked_int_tests.cpp index b7465afd..c3d9fb9a 100644 --- a/test/checked_int_tests.cpp +++ b/test/checked_int_tests.cpp @@ -4,7 +4,10 @@ #include #include +#include +#include #include +#include using formula::detail::add_checked_or_none; using formula::detail::decimal_digits; @@ -237,3 +240,54 @@ TEST_CASE("decimal_digits counts the digits of the magnitude", "[checked_int]") CHECK(decimal_digits(Int { -100 }) == 3); CHECK(decimal_digits(Int { 999999999999999999 }) == 18); } + +TEST_CASE("the 128-bit checked operations refuse exactly at the range's ends", "[checked-int]") +{ + using formula::Int128; + using formula::detail::add_checked_or_none; + using formula::detail::mul_checked_or_none; + using formula::detail::sub_checked_or_none; + constexpr Int128 largest = std::numeric_limits::max(); + constexpr Int128 smallest = std::numeric_limits::min(); + STATIC_REQUIRE(add_checked_or_none(largest - 1, Int128 { 1 }) == std::optional { largest }); + STATIC_REQUIRE(add_checked_or_none(largest, Int128 { 1 }) == std::nullopt); + STATIC_REQUIRE(add_checked_or_none(smallest, Int128 { -1 }) == std::nullopt); + STATIC_REQUIRE(sub_checked_or_none(Int128 { -1 }, largest) == std::optional { smallest }); + STATIC_REQUIRE(sub_checked_or_none(smallest, Int128 { 1 }) == std::nullopt); + STATIC_REQUIRE(sub_checked_or_none(largest, Int128 { -1 }) == std::nullopt); + // 2^63 * 2^64 = 2^127: one past the largest, and exactly the smallest when negative. + constexpr Int128 twoTo63 = Int128 { 1 } << 63; + constexpr Int128 twoTo64 = Int128 { 1 } << 64; + STATIC_REQUIRE(mul_checked_or_none(twoTo63, twoTo64) == std::nullopt); + STATIC_REQUIRE(mul_checked_or_none(-twoTo63, twoTo64) == std::optional { smallest }); + STATIC_REQUIRE(mul_checked_or_none(twoTo63, -twoTo64) == std::optional { smallest }); + STATIC_REQUIRE(mul_checked_or_none(smallest, Int128 { -1 }) == std::nullopt); + STATIC_REQUIRE(mul_checked_or_none(smallest, Int128 { 1 }) == std::optional { smallest }); + STATIC_REQUIRE(mul_checked_or_none(Int128 { 0 }, smallest) == std::optional { Int128 { 0 } }); +} + +TEST_CASE("the helpers that read an integer of either width", "[checked-int]") +{ + using formula::Int128; + using formula::detail::UInt128; + STATIC_REQUIRE(formula::detail::wide_magnitude(std::int64_t { -5 }) == UInt128::from_u64(5)); + STATIC_REQUIRE(formula::detail::wide_magnitude(std::numeric_limits::min()) + == UInt128::from_u64(std::uint64_t { 1 } << 63)); + STATIC_REQUIRE(formula::detail::wide_magnitude(std::numeric_limits::min()) + == UInt128 { std::uint64_t { 1 } << 63, 0 }); + STATIC_REQUIRE(formula::detail::narrow_to_int64(Int128 { -7 }) == std::optional { -7 }); + STATIC_REQUIRE(formula::detail::narrow_to_int64(Int128 { 1 } << 63) == std::nullopt); + STATIC_REQUIRE(formula::detail::int_from_pattern(UInt128 { ~std::uint64_t { 0 }, ~std::uint64_t { 0 } - 4 }, + std::type_identity {}) + == Int128 { -5 }); + STATIC_REQUIRE(formula::detail::int_from_pattern(UInt128 { ~std::uint64_t { 0 }, ~std::uint64_t { 0 } - 4 }, + std::type_identity {}) + == std::int64_t { -5 }); + STATIC_REQUIRE(formula::detail::floor_divmod(Int128 { -7 }, Int128 { 2 }).quotient == Int128 { -4 }); + STATIC_REQUIRE(formula::detail::floor_divmod(Int128 { -7 }, Int128 { 2 }).remainder == Int128 { 1 }); + STATIC_REQUIRE(formula::detail::decimal_digits(std::numeric_limits::max()) == 39); + 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); +} diff --git a/test/conformity_tests.cpp b/test/conformity_tests.cpp index 777d9938..9df36efd 100644 --- a/test/conformity_tests.cpp +++ b/test/conformity_tests.cpp @@ -342,7 +342,7 @@ TEST_CASE("the trace keeps the rows as judged, even if the check is changed afte TEST_CASE("a limit whose conversion fails makes its element invalid, on either side", "[conformity]") { // A kilometre limit near Rational's limit overflows reading into metres. - constexpr auto huge = limit(rat(std::numeric_limits::max())); + constexpr auto huge = limit(formula::Rational { std::numeric_limits::max() }); constexpr auto one = formula::environment(formula::measured_series(formula::Measured { rat(127) })); constexpr auto lowerSide = formula::check_conformity( formula::conformity( @@ -354,6 +354,20 @@ TEST_CASE("a limit whose conversion fails makes its element invalid, on either s one); STATIC_REQUIRE(lowerSide[0] == ConstraintOutcome::invalid(formula::ArithmeticError::Overflow)); STATIC_REQUIRE(upperSide[0] == ConstraintOutcome::invalid(formula::ArithmeticError::Overflow)); + + // The largest 64-bit number of kilometres reads into metres, and 127 mm + // is below it: under it as a lower bound, inside it as an upper one. + constexpr auto huge64 = limit(rat(std::numeric_limits::max())); + constexpr auto lowerSide64 = formula::check_conformity( + formula::conformity( + formula::series, formula::Envelope<1> { LimitRow { huge64, unbounded } }, reject), + one); + constexpr auto upperSide64 = formula::check_conformity( + formula::conformity( + formula::series, formula::Envelope<1> { LimitRow { unbounded, huge64 } }, reject), + one); + STATIC_REQUIRE(lowerSide64[0] == ConstraintOutcome::violated(reject)); + STATIC_REQUIRE(upperSide64[0] == ConstraintOutcome::satisfied()); } TEST_CASE("a loader builds an envelope from rows read at run time, its count checked", "[conformity]") diff --git a/test/consumer_globals_run_tests.cpp b/test/consumer_globals_run_tests.cpp index c9c8620e..c6ccccfb 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() == 118); + REQUIRE(probe.checks.size() == 119); 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 d179358c..36089a27 100644 --- a/test/consumer_globals_tests.cpp +++ b/test/consumer_globals_tests.cpp @@ -80,7 +80,8 @@ // `checked_number_text`, `decimal_text`, `fraction_text` and // `exact_decimal_text`; a `Rational` and a measured value written by // `std::format`, aligned and rounded, and an `Outcome`, a `Unit`, a -// `Dimension` and an enumeration written the same way; `symbol_of` with no +// `Dimension` and an enumeration written the same way; an `Int128` through +// every operator, and written by `std::format`; `symbol_of` with no // vocabulary, and `render` and `document` given `RenderOptions` and none; // `yields` of the formula touching every node kind, and `evaluate`, // `checked_evaluate`, `explain`, `checked_explain`, `trace_of`, `render`, `document` and @@ -187,6 +188,7 @@ int index; #include #include #include +#include #include #include #include @@ -1280,6 +1282,27 @@ ConsumerGlobalsProbe probe_consumer_globals() && std::format("{}", formula::unit::Millimetre) == "mm" && std::format("{}", formula::dim::Density) == "L^-3 M^1" && std::format("{}", formula::ArithmeticError::Overflow) == "overflow in exact arithmetic"); + // A 128-bit integer through its constructor template, every operator and + // std::format: ((-7 * 3 + 1 - 4) / 2 % 5 << 3 >> 1), stepped up and down, + // is -8. + formula::Int128 wideInteger { std::int16_t { -7 } }; + wideInteger = +(wideInteger * formula::Int128 { 3U } + 1 - 4); + wideInteger += 2; + wideInteger -= 2; + wideInteger *= 1; + wideInteger = wideInteger / 2 % 5; + wideInteger /= 1; + wideInteger %= 100; + wideInteger = (wideInteger << 3) >> 1; + wideInteger <<= 1; + wideInteger >>= 1; + ++wideInteger; + --wideInteger; + (void) wideInteger++; + (void) wideInteger--; + probe.checks.push_back(wideInteger == -wideInteger * -1 && wideInteger < formula::Int128 {} + && wideInteger.to_int64() == std::optional { -8 } + && std::format("{}", wideInteger) == "-8"); probe.checks.push_back( formula::symbol_of() == formula::Describe::symbol && formula::render(formula::var * formula::Rational { 3, 5 }, diff --git a/test/evaluate_tests.cpp b/test/evaluate_tests.cpp index f430f21d..8c038a09 100644 --- a/test/evaluate_tests.cpp +++ b/test/evaluate_tests.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -296,16 +297,24 @@ TEST_CASE("evaluate: a value in a different unit converts exactly", "[evaluate]" TEST_CASE("evaluate: an overflowing computation is reported, not wrapped", "[evaluate]") { // Anything above the square root of the representable range cannot be - // squared: sqrt(INT64_MAX) is about 3.04e9, so 4e9 divided by 1/4e9 -- a - // cross-reduction-proof 1.6e19 -- overflows outright. (3e9 does not: see - // the companion test below, which pins exactly where the edge is.) - constexpr std::int64_t huge = 4'000'000'000LL; - auto const big = - formula::environment(formula::Measured { rat(huge) }, formula::Measured { rat(1, huge) }); + // squared: sqrt(2^127) is about 1.3e19, so 2e19 divided by 1/2e19 -- a + // cross-reduction-proof 4e38 -- overflows outright. + constexpr formula::Rational::Int huge = formula::Rational::Int { 10'000'000'000'000'000'000ULL } * 2; + auto const big = formula::environment(formula::Measured { formula::Rational { huge } }, + formula::Measured { formula::Rational { 1, huge } }); auto const computed = formula::checked_evaluate(ratio, big); REQUIRE_FALSE(computed.has_value()); CHECK(computed.error() == formula::ArithmeticError::Overflow); + + // 4e9 over 1/4e9, which overflowed 64 bits, is 1.6e19. + constexpr std::int64_t huge64 = 4'000'000'000LL; + auto const big64 = formula::environment(formula::Measured { rat(huge64) }, + formula::Measured { rat(1, huge64) }); + auto const computed64 = formula::checked_evaluate(ratio, big64); + REQUIRE(computed64.has_value()); + REQUIRE(computed64->is_value()); + CHECK(computed64->measurement().value() == formula::Rational { formula::Rational::Int { huge64 } * huge64 }); } TEST_CASE("evaluate: the double representation still reports an overflow from the leaf conversion", "[evaluate]") @@ -313,10 +322,11 @@ TEST_CASE("evaluate: the double representation still reports an overflow from th // RepTraits's own arithmetic cannot overflow (the doc comment // above it says so), but detail::in_si converts every leaf in exact // Rational before handing it to RepTraits::from, and that conversion - // can overflow on its own -- IntMax kilometres times a magnitude of 1000 - // overflows the exact multiply long before any double arithmetic runs. - constexpr std::int64_t huge = formula::detail::IntMax; - auto const farInputs = formula::environment(formula::Measured { rat(huge) }); + // can overflow on its own -- the largest Rational::Int of kilometres times + // a magnitude of 1000 overflows the exact multiply long before any double + // arithmetic runs. + constexpr formula::Rational::Int huge = std::numeric_limits::max(); + auto const farInputs = formula::environment(formula::Measured { formula::Rational { huge } }); auto const computed = formula::checked_evaluate_si(var, farInputs); REQUIRE_FALSE(computed.has_value()); @@ -325,19 +335,20 @@ TEST_CASE("evaluate: the double representation still reports an overflow from th TEST_CASE("evaluate: a result at the edge of the range is computed, not refused", "[evaluate]") { - // 3e9 litres over 1/3e9 litres is exactly 9e18, which fits in a 64-bit - // integer with room to spare. It fits only because `checked_mul` - // cross-reduces before multiplying; a naive implementation would overflow - // on the way to a representable answer. This is the companion to the - // overflow test above: together they say where the edge actually is. - constexpr std::int64_t large = 3'000'000'000LL; - auto const edgeInputs = formula::environment(formula::Measured { rat(large) }, - formula::Measured { rat(1, large) }); + // 1.3e19 litres over 1/1.3e19 litres is exactly 1.69e38, which fits below + // 2^127 (1.70e38). It fits only because `checked_mul` cross-reduces before + // multiplying; a naive implementation would overflow on the way to a + // representable answer. This is the companion to the overflow test above, + // at 2e19: together they say where the edge actually is, at the square + // root of 2^127, about 1.30e19. + constexpr formula::Rational::Int large { 13'000'000'000'000'000'000ULL }; + auto const edgeInputs = formula::environment(formula::Measured { formula::Rational { large } }, + formula::Measured { formula::Rational { 1, large } }); auto const computed = formula::checked_evaluate(ratio, edgeInputs); REQUIRE(computed.has_value()); REQUIRE(computed->is_value()); - CHECK(computed->measurement().value() == rat(9'000'000'000'000'000'000LL)); + CHECK(computed->measurement().value() == formula::Rational { large * large }); } TEST_CASE("evaluate: a measured result is not mistaken for an override", "[evaluate]") diff --git a/test/format_tests.cpp b/test/format_tests.cpp index df673df7..575bc10f 100644 --- a/test/format_tests.cpp +++ b/test/format_tests.cpp @@ -240,30 +240,37 @@ TEST_CASE("a spec the grammar does not allow is refused at run time, in the libr CHECK(refusalOf("{:~HalfEven}", Measured { third }).empty()); // `~Mode` at a unit's negative decimals divides the value by 10^3 in - // exact arithmetic, which overflows for from_double_exact(0.1) -- its - // denominator is 2^55 -- although the rounded value would be 0. It is + // exact arithmetic, which overflows for 2^-120 -- its denominator times + // 10^3 is past 2^127 -- although the rounded value would be 0. It is // refused when written, with a literal format string and under // std::vformat alike, rather than spelled some other way. - Rational const binaryTenth = Rational::from_double_exact(0.1).value(); - REQUIRE(binaryTenth == Rational { 3602879701896397, 36028797018963968 }); - Measured const coarseTenth { binaryTenth }; - CHECK(refusalOf("{:~HalfEven}", coarseTenth) + Measured const coarseTiny { Rational { 1, Rational::Int { 1 } << 120 } }; + CHECK(refusalOf("{:~HalfEven}", coarseTiny) == "formula: this number cannot be spelled as the format asks: overflow in exact arithmetic"); - CHECK_THROWS_AS(std::format("{:~HalfEven}", coarseTenth), std::format_error); + CHECK_THROWS_AS(std::format("{:~HalfEven}", coarseTiny), std::format_error); // Its exact forms are still written; and in the same unit a value that // exact arithmetic can round rounds: 7501/3 is 2500.33..., 3000 to the // thousand. - CHECK(std::format("{:/}", coarseTenth) == "3602879701896397/36028797018963968 ku"); + CHECK(std::format("{:/}", coarseTiny) == "1/1329227995784915872903807060280344576 ku"); // What to write instead, as the guide says: places of 0 to 18 are spelled // by long division, which cannot overflow. - CHECK(std::format("{:~.0HalfEven}", coarseTenth) == "\xe2\x89\x88" "0 ku"); + CHECK(std::format("{:~.0HalfEven}", coarseTiny) == "\xe2\x89\x88" "0 ku"); CHECK(std::format("{:~HalfEven}", Measured { Rational { 7501, 3 } }) == "\xe2\x89\x88" "3000 ku"); + // from_double_exact(0.1), whose denominator of 2^55 times 10^3 overflowed + // 64 bits, rounds to 0 thousand. + Rational const binaryTenth = Rational::from_double_exact(0.1).value(); + REQUIRE(binaryTenth == Rational { 3602879701896397, 36028797018963968 }); + Measured const coarseTenth { binaryTenth }; + CHECK(std::format("{:~HalfEven}", coarseTenth) == "\xe2\x89\x88" "0 ku"); + CHECK(std::format("{:/}", coarseTenth) == "3602879701896397/36028797018963968 ku"); // A value with an exact decimal of at most 18 places is written as it - // is, never rounded, so never divided: 1/10^18, whose rounding at -3 - // places overflows, is written exactly. + // is, never rounded: 1/10^18, which rounds to 0 at -3 places, is written + // exactly. Rational const atto { 1, 1'000'000'000'000'000'000 }; - REQUIRE(!formula::checked_decimal_text( - atto, formula::DecimalPlaces { -3 }, RoundingMode::HalfEven, formula::DecimalPadding::Trimmed)); + auto const attoRounded = formula::checked_decimal_text( + atto, formula::DecimalPlaces { -3 }, RoundingMode::HalfEven, formula::DecimalPadding::Trimmed); + REQUIRE(attoRounded.has_value()); + CHECK(attoRounded->view() == "0"); CHECK(std::format("{:~HalfEven}", Measured { atto }) == "0.000000000000000001 ku"); } diff --git a/test/function_tests.cpp b/test/function_tests.cpp index ab34dc6e..50d23843 100644 --- a/test/function_tests.cpp +++ b/test/function_tests.cpp @@ -298,9 +298,14 @@ TEST_CASE("function: ln log10 and exp are exact where their value is rational", STATIC_REQUIRE(**exactlyAt(formula::log10(var), rat(1000)) == rat(3)); // A power of ten written as one over a power of ten. STATIC_REQUIRE(**exactlyAt(formula::log10(var), rat(1, 100)) == rat(-2)); - // The largest powers of ten a Rational holds, either way up. + // 10^18, the largest power of ten a 64-bit integer holds, either way up. STATIC_REQUIRE(**exactlyAt(formula::log10(var), rat(1'000'000'000'000'000'000)) == rat(18)); STATIC_REQUIRE(**exactlyAt(formula::log10(var), rat(1, 1'000'000'000'000'000'000)) == rat(-18)); + // 10^38, the largest power of ten a Rational holds, either way up. + constexpr formula::Rational::Int tenToNineteen = formula::Rational::Int { 1'000'000'000'000'000'000 } * 10; + constexpr formula::Rational::Int tenToThirtyEight = tenToNineteen * tenToNineteen; + STATIC_REQUIRE(**exactlyAt(formula::log10(var), formula::Rational { tenToThirtyEight }) == rat(38)); + STATIC_REQUIRE(**exactlyAt(formula::log10(var), formula::Rational { 1, tenToThirtyEight }) == rat(-38)); } TEST_CASE("function: a logarithm or an exponential of any other value is Inexact in Rational", "[function]") diff --git a/test/int128_tests.cpp b/test/int128_tests.cpp new file mode 100644 index 00000000..52e5423a --- /dev/null +++ b/test/int128_tests.cpp @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// formula::Int128: two's complement arithmetic on 128 bits, the same at +// compile time and at run time, and the same through the compiler's own +// 128-bit integer and through the portable code. Expected values were +// computed with Python's integers. +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using formula::Int128; +using formula::detail::UInt128; + +constexpr Int128 words(std::uint64_t highWord, std::uint64_t lowWord) noexcept +{ + return Int128::from_words(highWord, lowWord); +} + +constexpr UInt128 unsigned_words(std::uint64_t highWord, std::uint64_t lowWord) noexcept +{ + return UInt128 { highWord, lowWord }; +} + +struct ArithmeticCase +{ + Int128 leftOperand; + Int128 rightOperand; + Int128 added; + Int128 subtracted; + Int128 multiplied; + Int128 divided; + Int128 remaining; +}; + +// Quotients round toward zero; a remainder takes the dividend's sign. Some +// sums, differences and products in the first nine rows go past the range +// and pin the two's complement bits both routes leave there. The contract +// makes such an operation a precondition violation, so those check that the +// routes agree, not a promise. +constexpr std::array arithmeticCases { { + { words(0x7fffffffffffffff, 0xffffffffffffffff), words(0x0000000000000000, 0x0000000000000003), + words(0x8000000000000000, 0x0000000000000002), words(0x7fffffffffffffff, 0xfffffffffffffffc), + words(0x7fffffffffffffff, 0xfffffffffffffffd), words(0x2aaaaaaaaaaaaaaa, 0xaaaaaaaaaaaaaaaa), + words(0x0000000000000000, 0x0000000000000001) }, + { words(0x8000000000000000, 0x0000000000000000), words(0x0000000000000000, 0x0000000000000007), + words(0x8000000000000000, 0x0000000000000007), words(0x7fffffffffffffff, 0xfffffffffffffff9), + words(0x8000000000000000, 0x0000000000000000), words(0xedb6db6db6db6db6, 0xdb6db6db6db6db6e), + words(0xffffffffffffffff, 0xfffffffffffffffe) }, + { words(0x0000000000000001, 0x0000000000003039), words(0x0000000000000000, 0xffffffffffffffff), + words(0x0000000000000002, 0x0000000000003038), words(0x0000000000000000, 0x000000000000303a), + words(0x0000000000003037, 0xffffffffffffcfc7), words(0x0000000000000000, 0x0000000000000001), + words(0x0000000000000000, 0x000000000000303a) }, + { words(0xfffffffe7116f009, 0x3c8c1f11b1c0f52e), words(0x0000000000000000, 0x0db4da5f49f8b478), + words(0xfffffffe7116f009, 0x4a40f970fbb9a9a6), words(0xfffffffe7116f009, 0x2ed744b267c840b6), + words(0x4b38a08ad7aa0090, 0x0e176230a1674590), words(0xffffffffffffffff, 0xffffffe2e56b6274), + words(0xffffffffffffffff, 0xf326734031d13ece) }, + { words(0x7fffffffffffffff, 0xffffffffffffffff), words(0x8000000000000000, 0x0000000000000001), + words(0x0000000000000000, 0x0000000000000000), words(0xffffffffffffffff, 0xfffffffffffffffe), + words(0xffffffffffffffff, 0xffffffffffffffff), words(0xffffffffffffffff, 0xffffffffffffffff), + words(0x0000000000000000, 0x0000000000000000) }, + { words(0x0000001000000000, 0x0000000000000001), words(0xffffffffffffffff, 0xffffffeffffffffb), + words(0x0000000fffffffff, 0xffffffeffffffffc), words(0x0000001000000000, 0x0000001000000006), + words(0xffffffafffffffff, 0xffffffeffffffffb), words(0xffffffffffffffff, 0x0000000050000000), + words(0x0000000000000000, 0x0000000190000001) }, + { words(0xffffffffffffffff, 0xfffffffffffffffb), words(0x0000000000000000, 0x0000000000000003), + words(0xffffffffffffffff, 0xfffffffffffffffe), words(0xffffffffffffffff, 0xfffffffffffffff8), + words(0xffffffffffffffff, 0xfffffffffffffff1), words(0xffffffffffffffff, 0xffffffffffffffff), + words(0xffffffffffffffff, 0xfffffffffffffffe) }, + { words(0x0000000000000000, 0x0000000000000005), words(0xffffffffffffffff, 0xfffffffffffffffd), + words(0x0000000000000000, 0x0000000000000002), words(0x0000000000000000, 0x0000000000000008), + words(0xffffffffffffffff, 0xfffffffffffffff1), words(0xffffffffffffffff, 0xffffffffffffffff), + words(0x0000000000000000, 0x0000000000000002) }, + { words(0xffffffffffffffff, 0x0000000000000000), words(0xffffffffffffffff, 0x8000000000000000), + words(0xfffffffffffffffe, 0x8000000000000000), words(0xffffffffffffffff, 0x8000000000000000), + words(0x8000000000000000, 0x0000000000000000), words(0x0000000000000000, 0x0000000000000002), + words(0x0000000000000000, 0x0000000000000000) }, + // Boundary operands, each row's every result in range: 0, +-1, +-2^63, + // 2^64 and -(2^127 - 1). + { words(0x0000000000000000, 0x0000000000000000), words(0x0000000000000000, 0x0000000000000001), + words(0x0000000000000000, 0x0000000000000001), words(0xffffffffffffffff, 0xffffffffffffffff), + words(0x0000000000000000, 0x0000000000000000), words(0x0000000000000000, 0x0000000000000000), + words(0x0000000000000000, 0x0000000000000000) }, + { words(0x0000000000000000, 0x0000000000000000), words(0xffffffffffffffff, 0xffffffffffffffff), + words(0xffffffffffffffff, 0xffffffffffffffff), words(0x0000000000000000, 0x0000000000000001), + words(0x0000000000000000, 0x0000000000000000), words(0x0000000000000000, 0x0000000000000000), + words(0x0000000000000000, 0x0000000000000000) }, + { words(0x0000000000000000, 0x8000000000000000), words(0xffffffffffffffff, 0x8000000000000000), + words(0x0000000000000000, 0x0000000000000000), words(0x0000000000000001, 0x0000000000000000), + words(0xc000000000000000, 0x0000000000000000), words(0xffffffffffffffff, 0xffffffffffffffff), + words(0x0000000000000000, 0x0000000000000000) }, + { words(0xffffffffffffffff, 0x8000000000000000), words(0x0000000000000000, 0x0000000000000001), + words(0xffffffffffffffff, 0x8000000000000001), words(0xffffffffffffffff, 0x7fffffffffffffff), + words(0xffffffffffffffff, 0x8000000000000000), words(0xffffffffffffffff, 0x8000000000000000), + words(0x0000000000000000, 0x0000000000000000) }, + { words(0xffffffffffffffff, 0xffffffffffffffff), words(0x0000000000000000, 0x8000000000000000), + words(0x0000000000000000, 0x7fffffffffffffff), words(0xffffffffffffffff, 0x7fffffffffffffff), + words(0xffffffffffffffff, 0x8000000000000000), words(0x0000000000000000, 0x0000000000000000), + words(0xffffffffffffffff, 0xffffffffffffffff) }, + { words(0x0000000000000001, 0x0000000000000000), words(0xffffffffffffffff, 0x8000000000000000), + words(0x0000000000000000, 0x8000000000000000), words(0x0000000000000001, 0x8000000000000000), + words(0x8000000000000000, 0x0000000000000000), words(0xffffffffffffffff, 0xfffffffffffffffe), + words(0x0000000000000000, 0x0000000000000000) }, + { words(0x8000000000000000, 0x0000000000000001), words(0xffffffffffffffff, 0xffffffffffffffff), + words(0x8000000000000000, 0x0000000000000000), words(0x8000000000000000, 0x0000000000000002), + words(0x7fffffffffffffff, 0xffffffffffffffff), words(0x7fffffffffffffff, 0xffffffffffffffff), + words(0x0000000000000000, 0x0000000000000000) }, +} }; + +constexpr bool every_arithmetic_case_holds() noexcept +{ + for (ArithmeticCase const& checked: arithmeticCases) + { + if (!(checked.leftOperand + checked.rightOperand == checked.added) + || !(checked.leftOperand - checked.rightOperand == checked.subtracted) + || !(checked.leftOperand * checked.rightOperand == checked.multiplied) + || !(checked.leftOperand / checked.rightOperand == checked.divided) + || !(checked.leftOperand % checked.rightOperand == checked.remaining)) + return false; + } + return true; +} + +struct GcdCase +{ + UInt128 leftOperand; + UInt128 rightOperand; + UInt128 common; +}; + +constexpr std::array gcdCases { { + // 2^127 - 1 and 2^64 - 1: 2^gcd(127, 64) - 1 = 1. + { unsigned_words(0x7fffffffffffffff, 0xffffffffffffffff), unsigned_words(0, 0xffffffffffffffff), unsigned_words(0, 1) }, + // 2^90 3^20 and 2^80 3^25 5: 2^80 3^20. + { unsigned_words(0x033f506e44000000, 0), unsigned_words(0x03da5faed52f0000, 0), unsigned_words(0x0000cfd41b910000, 0) }, + { unsigned_words(0x7fffffffffffffff, 0xffffffffffffffff), unsigned_words(0x7fffffffffffffff, 0xffffffffffffffff), + unsigned_words(0x7fffffffffffffff, 0xffffffffffffffff) }, + { unsigned_words(0, 0), unsigned_words(0x0000001000000000, 0), unsigned_words(0x0000001000000000, 0) }, + // 6 * 10^30 and 4 * 10^25 + 2. + { unsigned_words(0x0000004bbb0bace1, 0xa6bd937d80000000), unsigned_words(0x0000000000211654, 0x5850052128000002), + unsigned_words(0, 6) }, + { unsigned_words(7, 0), unsigned_words(0x4d, 0), unsigned_words(7, 0) }, +} }; + +/// Whether @p T's least and greatest values become the Int128 of the same +/// value: sign-extended into the high word, exactly. +template +constexpr bool converts_exactly() noexcept +{ + constexpr T smallestOf = std::numeric_limits::min(); + constexpr T largestOf = std::numeric_limits::max(); + bool const largestKept = Int128 { largestOf } == words(0, static_cast(largestOf)); + if constexpr (std::is_signed_v) + return largestKept + && Int128 { smallestOf } + == words(~std::uint64_t { 0 }, static_cast(static_cast(smallestOf))); + else + return largestKept && Int128 { smallestOf } == Int128 {}; +} +} // namespace + +TEST_CASE("Int128 adds, subtracts, multiplies and divides as a 128-bit two's complement integer", "[int128]") +{ + for (ArithmeticCase const& checked: arithmeticCases) + { + CHECK(checked.leftOperand + checked.rightOperand == checked.added); + CHECK(checked.leftOperand - checked.rightOperand == checked.subtracted); + CHECK(checked.leftOperand * checked.rightOperand == checked.multiplied); + CHECK(checked.leftOperand / checked.rightOperand == checked.divided); + CHECK(checked.leftOperand % checked.rightOperand == checked.remaining); + } +} + +TEST_CASE("Int128 computes the same at compile time", "[int128]") +{ + STATIC_REQUIRE(every_arithmetic_case_holds()); +} + +TEST_CASE("Int128 converts from every built-in integer exactly, and to none without asking", "[int128]") +{ + STATIC_REQUIRE(Int128 { -5 } == words(~std::uint64_t { 0 }, ~std::uint64_t { 0 } - 4)); + STATIC_REQUIRE(Int128 { std::numeric_limits::max() } == words(0, ~std::uint64_t { 0 })); + STATIC_REQUIRE(Int128 { std::numeric_limits::min() } == words(~std::uint64_t { 0 }, std::uint64_t { 1 } << 63)); + STATIC_REQUIRE(converts_exactly()); + STATIC_REQUIRE(converts_exactly()); + STATIC_REQUIRE(converts_exactly()); + STATIC_REQUIRE(converts_exactly()); + STATIC_REQUIRE(converts_exactly()); + STATIC_REQUIRE(converts_exactly()); + STATIC_REQUIRE(converts_exactly()); + STATIC_REQUIRE(converts_exactly()); + STATIC_REQUIRE(converts_exactly()); + STATIC_REQUIRE(converts_exactly()); + STATIC_REQUIRE(converts_exactly()); + STATIC_REQUIRE_FALSE(std::is_constructible_v); + // No conversion to a built-in integer, implicit or explicit: narrowing is + // to_int64() or to_uint64(), which say when the value does not fit. + STATIC_REQUIRE_FALSE(std::is_convertible_v); + STATIC_REQUIRE_FALSE(std::is_constructible_v); + STATIC_REQUIRE_FALSE(std::is_constructible_v); + STATIC_REQUIRE(Int128 { -1 }.to_int64() == std::optional { -1 }); + STATIC_REQUIRE(Int128 { std::numeric_limits::max() }.to_int64() == std::nullopt); + STATIC_REQUIRE(Int128 { std::numeric_limits::max() }.to_uint64() + == std::optional { std::numeric_limits::max() }); + STATIC_REQUIRE(Int128 { -1 }.to_uint64() == std::nullopt); + STATIC_REQUIRE(words(1, 0).to_int64() == std::nullopt); +} + +TEST_CASE("Int128 orders as a signed integer and shifts arithmetically", "[int128]") +{ + constexpr Int128 smallest = std::numeric_limits::min(); + constexpr Int128 largest = std::numeric_limits::max(); + STATIC_REQUIRE(smallest == words(std::uint64_t { 1 } << 63, 0)); + STATIC_REQUIRE(largest == words(~(std::uint64_t { 1 } << 63), ~std::uint64_t { 0 })); + STATIC_REQUIRE(smallest < Int128 { -1 }); + STATIC_REQUIRE(Int128 { -1 } < Int128 { 0 }); + STATIC_REQUIRE(words(0, ~std::uint64_t { 0 }) < words(1, 0)); + STATIC_REQUIRE(0 < largest); + STATIC_REQUIRE((Int128 { -8 } >> 1) == Int128 { -4 }); + STATIC_REQUIRE((Int128 { -1 } >> 127) == Int128 { -1 }); + STATIC_REQUIRE((smallest >> 64) == words(~std::uint64_t { 0 }, std::uint64_t { 1 } << 63)); + STATIC_REQUIRE((Int128 { 1 } << 127) == smallest); + STATIC_REQUIRE((Int128 { 3 } << 64) == words(3, 0)); + STATIC_REQUIRE((Int128 { 1 } << 63) == words(0, std::uint64_t { 1 } << 63)); + // A shift by nothing leaves a negative value as it is. + STATIC_REQUIRE((smallest >> 0) == smallest); + STATIC_REQUIRE((Int128 { -5 } >> 63) == -1); + STATIC_REQUIRE(std::numeric_limits::digits == 127); + STATIC_REQUIRE(std::numeric_limits::is_signed); +} + +TEST_CASE("Int128 divides the minimum, and the checked product refuses exactly past 128 bits", "[int128]") +{ + using formula::detail::u128_mul_checked; + namespace portable = formula::detail::portable; + constexpr Int128 smallest = std::numeric_limits::min(); + constexpr std::uint64_t allOnes = ~std::uint64_t { 0 }; + // -2^127 as a divisor, and by -1 for the remainder; -2^127 / -1 itself + // does not fit, and is outside the contract. + STATIC_REQUIRE(smallest / smallest == 1); + STATIC_REQUIRE((smallest + 1) / smallest == 0); + STATIC_REQUIRE((smallest + 1) % smallest == smallest + 1); + STATIC_REQUIRE(smallest % -1 == 0); + // -2^127 / 1 fits: the quotient's magnitude, 2^127, is negated as a bit + // pattern. + STATIC_REQUIRE(smallest / 1 == smallest); + STATIC_REQUIRE(smallest % 1 == 0); + // 2^64 * (2^64 - 1) is the largest product of these shapes that fits; + // 2^64 * 2^64 is 2^128, one past. + STATIC_REQUIRE(u128_mul_checked(UInt128 { 1, 0 }, UInt128 { 0, allOnes }) == UInt128 { allOnes, 0 }); + STATIC_REQUIRE(!u128_mul_checked(UInt128 { 1, 0 }, UInt128 { 1, 0 }).has_value()); + // The portable route on its own, on every compiler: (2^64 + 1)(2^64 - 1) + // is 2^128 - 1 and fits; (2^64 + 2)(2^64 - 1) overflows only through the + // carry into the high word. + STATIC_REQUIRE(portable::multiply_checked(UInt128 { 1, 1 }, UInt128 { 0, allOnes }) == UInt128 { allOnes, allOnes }); + STATIC_REQUIRE(!portable::multiply_checked(UInt128 { 1, 2 }, UInt128 { 0, allOnes }).has_value()); + STATIC_REQUIRE(!portable::multiply_checked(UInt128 { 1, 0 }, UInt128 { 1, 0 }).has_value()); + // 2^65 * 2^63 is 2^128: the cross term alone needs more than 64 bits. + STATIC_REQUIRE(!portable::multiply_checked(UInt128 { 2, 0 }, UInt128 { 0, std::uint64_t { 1 } << 63 }).has_value()); +} + +TEST_CASE("an Int128 is made from a magnitude and a sign, up to 2^127 for a negative one", "[int128]") +{ + using formula::detail::signed_from_magnitude; + STATIC_REQUIRE(signed_from_magnitude(UInt128 { std::uint64_t { 1 } << 63, 0 }, true) == std::numeric_limits::min()); + STATIC_REQUIRE(signed_from_magnitude(UInt128 { ~(std::uint64_t { 1 } << 63), ~std::uint64_t { 0 } }, false) + == std::numeric_limits::max()); + STATIC_REQUIRE(signed_from_magnitude(UInt128 { 0, 5 }, true) == -5); + STATIC_REQUIRE(signed_from_magnitude(UInt128 {}, true) == 0); + STATIC_REQUIRE(formula::detail::magnitude(std::numeric_limits::min()) == UInt128 { std::uint64_t { 1 } << 63, 0 }); +} + +TEST_CASE("Int128 converts to the nearest double, ties to even", "[int128]") +{ + CHECK(words(0x7fffffffffffffff, 0xffffffffffffffff).to_double() == 0x1p+127); + CHECK(words(0x8000000000000000, 0).to_double() == -0x1p+127); + // 2^64 + 2^11 is half way between two doubles: to even, 2^64. + CHECK(words(1, 0x800).to_double() == 0x1p+64); + // One more and it is past half way. + CHECK(words(1, 0x801).to_double() == 0x1.0000000000001p+64); + // 2^64 + 3 * 2^11 is half way again: to even, upwards this time. + CHECK(words(1, 0x1800).to_double() == 0x1.0000000000002p+64); + CHECK(words(0xffffffefffffffff, 0xffff800000000000).to_double() == -0x1p+100); + CHECK(words(0xffffffefffffffff, 0xffff7fffffffffff).to_double() == -0x1.0000000000001p+100); + CHECK(Int128 { -7 }.to_double() == -7.0); + // Magnitudes in [2^63, 2^64) drop no bits: 2^64 - 2^11 is a double, and + // 2^64 - 1 rounds up to 2^64. + CHECK(words(0, 0xfffffffffffff800).to_double() == 0x1.fffffffffffffp+63); + CHECK(words(~std::uint64_t { 0 }, 0x800).to_double() == -0x1.fffffffffffffp+63); + CHECK(words(0, ~std::uint64_t { 0 }).to_double() == 0x1p+64); + CHECK(words(0, std::uint64_t { 1 } << 63).to_double() == 0x1p+63); +} + +TEST_CASE("the 128-bit greatest common divisor, square root and powers of ten", "[int128]") +{ + for (GcdCase const& checked: gcdCases) + { + CHECK(formula::detail::u128_gcd(checked.leftOperand, checked.rightOperand) == checked.common); + CHECK(formula::detail::u128_gcd(checked.rightOperand, checked.leftOperand) == checked.common); + } + CHECK(formula::detail::u128_isqrt(unsigned_words(~std::uint64_t { 0 }, ~std::uint64_t { 0 })) == ~std::uint64_t { 0 }); + CHECK(formula::detail::u128_isqrt(unsigned_words(std::uint64_t { 1 } << 63, 0)) == 0xb504f333f9de6484); + CHECK(formula::detail::u128_isqrt(unsigned_words(1, 0)) == std::uint64_t { 1 } << 32); + // (2^64 - 1)^2 and one below it. + CHECK(formula::detail::u128_isqrt(unsigned_words(0xfffffffffffffffe, 1)) == ~std::uint64_t { 0 }); + CHECK(formula::detail::u128_isqrt(unsigned_words(0xfffffffffffffffe, 0)) == 0xfffffffffffffffe); + CHECK(formula::detail::u128_pow10(0) == UInt128::from_u64(1)); + CHECK(formula::detail::u128_pow10(38) == unsigned_words(0x4b3b4ca85a86c47a, 0x098a224000000000)); + CHECK(formula::detail::u128_pow10(39) == std::nullopt); + CHECK(formula::detail::u128_pow10(-1) == std::nullopt); +} + +TEST_CASE("Int128 formats as its decimal digits", "[int128][format]") +{ + CHECK(std::format("{}", Int128 { 0 }) == "0"); + CHECK(std::format("{}", Int128 { -1 }) == "-1"); + CHECK(std::format("{}", std::numeric_limits::max()) == "170141183460469231731687303715884105727"); + CHECK(std::format("{}", std::numeric_limits::min()) == "-170141183460469231731687303715884105728"); + CHECK(std::format("{}", words(1, 0)) == "18446744073709551616"); + CHECK(std::format("{}", words(0x4b3b4ca85a86c47a, 0x098a224000000000)) == "100000000000000000000000000000000000000"); + // A spec built at run time is refused when used, in Int128's own words. + Int128 const shownInteger { 255 }; + std::string_view const hexadecimal = "{:x}"; + try + { + (void) std::vformat(hexadecimal, std::make_format_args(shownInteger)); + CHECK(false); + } + catch (std::format_error const& refusal) + { + CHECK(std::string_view { refusal.what() }.starts_with("formula: an Int128 is formatted only with {}")); + } +} + +TEST_CASE("the 128-bit division, product and greatest common divisor keep their defining identities", "[int128]") +{ + // splitmix64, seeded: a mix of 64-bit, 128-bit and boundary operands. + std::uint64_t state = 20261003; + auto const nextWord = [&state] { + state += 0x9E3779B97F4A7C15ULL; + std::uint64_t mixed = state; + mixed = (mixed ^ (mixed >> 30)) * 0xBF58476D1CE4E5B9ULL; + mixed = (mixed ^ (mixed >> 27)) * 0x94D049BB133111EBULL; + return mixed ^ (mixed >> 31); + }; + for (int drawn = 0; drawn < 4000; ++drawn) + { + std::uint64_t const shape = nextWord() % 4; + UInt128 const dividend { shape == 0 ? std::uint64_t { 0 } : nextWord(), nextWord() }; + UInt128 const divisor { shape < 2 ? std::uint64_t { 0 } : nextWord() >> (nextWord() % 64), nextWord() | 1U }; + formula::detail::UInt128Division const split = formula::detail::u128_divmod(dividend, divisor); + CHECK(split.remainder < divisor); + std::optional const recombined = formula::detail::u128_mul_checked(split.quotient, divisor); + REQUIRE(recombined.has_value()); + CHECK(formula::detail::u128_add(*recombined, split.remainder) == dividend); + // Coprimality is checked with u128_gcd itself, and every divisor here + // is odd, so these draws never reach the shared factors of two: the + // fixed gcd table above (its first, second and last rows) is what pins + // the early exit and the shared twos. Do not trim it. + UInt128 const common = formula::detail::u128_gcd(dividend, divisor); + CHECK(formula::detail::u128_divmod(dividend, common).remainder.is_zero()); + CHECK(formula::detail::u128_divmod(divisor, common).remainder.is_zero()); + CHECK(formula::detail::u128_gcd(formula::detail::u128_divmod(dividend, common).quotient, + formula::detail::u128_divmod(divisor, common).quotient) + == UInt128::from_u64(1)); + } +} + +#if FORMULA_NATIVE_INT128 +TEST_CASE("the portable 128-bit arithmetic agrees with the compiler's own", "[int128]") +{ + using namespace formula::detail; + std::uint64_t state = 1272026; + auto const nextWord = [&state] { + state += 0x9E3779B97F4A7C15ULL; + std::uint64_t mixed = state; + mixed = (mixed ^ (mixed >> 30)) * 0xBF58476D1CE4E5B9ULL; + mixed = (mixed ^ (mixed >> 27)) * 0x94D049BB133111EBULL; + return mixed ^ (mixed >> 31); + }; + for (int drawn = 0; drawn < 4000; ++drawn) + { + UInt128 const leftOperand { nextWord() % 3 == 0 ? std::uint64_t { 0 } : nextWord(), nextWord() }; + UInt128 const rightOperand { nextWord() % 3 == 0 ? std::uint64_t { 0 } : nextWord() >> (nextWord() % 64), + nextWord() | 1U }; + NativeUInt128 const nativeLeft = to_native(leftOperand); + NativeUInt128 const nativeRight = to_native(rightOperand); + CHECK(portable::multiply(leftOperand, rightOperand) == from_native(nativeLeft * nativeRight)); + CHECK(portable::divide(leftOperand, rightOperand).quotient == from_native(nativeLeft / nativeRight)); + CHECK(portable::divide(leftOperand, rightOperand).remainder == from_native(nativeLeft % nativeRight)); + NativeUInt128 nativeProduct = 0; + bool const nativeOverflowed = __builtin_mul_overflow(nativeLeft, nativeRight, &nativeProduct); + std::optional const portableProduct = portable::multiply_checked(leftOperand, rightOperand); + CHECK(portableProduct.has_value() == !nativeOverflowed); + if (portableProduct.has_value()) + CHECK(*portableProduct == from_native(nativeProduct)); + CHECK(portable::multiply_words(leftOperand.lowWord, rightOperand.lowWord) + == from_native(static_cast(leftOperand.lowWord) * rightOperand.lowWord)); + } +} +#endif diff --git a/test/join_tests.cpp b/test/join_tests.cpp index 26681a86..cfe682a6 100644 --- a/test/join_tests.cpp +++ b/test/join_tests.cpp @@ -93,11 +93,11 @@ TEST_CASE("an overlaid method's selected variant traces in the jurisdiction's vo "5. k_n = #4 = 241/127 [derived by jurisdiction overlay: Example Standard 12:2021 NA, NA.2.2]\n" "6. #1 * #5 = 358367/127000\n" "7. E = 30 MPa\n" - "8. #6 * #7 = 10751010000/127\n" + "8. #6 * #7 = 1075101/12700 MPa\n" "9. R = 11 MPa\n" "10. D = 241 mm\n" "11. lookup(#10) = 1973/1000 [163 to under 331 mm]\n" - "12. #9 * #11 = 21703000\n" + "12. #9 * #11 = 21703/1000 MPa\n" "13. #8 / #12 = 10751010/2756281\n" "14. #13 = 10751010/2756281 [replaced by jurisdiction overlay: Example Standard 12:2021 NA, NA.3]\n" "15. round(#14, in %) = 7801/20 % [rounded to 2 dp (jurisdiction overlay: Example Standard 12:2021 NA, " diff --git a/test/least_squares_tests.cpp b/test/least_squares_tests.cpp index c130d071..8501c140 100644 --- a/test/least_squares_tests.cpp +++ b/test/least_squares_tests.cpp @@ -230,7 +230,7 @@ TEST_CASE("two points give the exact line through them", "[least-squares]") namespace { // Point k at ((k + 1)/(k + 2) s, (2k + 3)/(k + 3) mm): a different -// denominator on every point, the spike's shape that overflows from 15 +// denominator on every point, the spike's shape that overflows from 27 // points (step 3). Invented, and ascending, as a curve's points must be. template [[nodiscard]] auto distinct_denominators() @@ -250,15 +250,15 @@ template TEST_CASE("a fit that exceeds Rational's range says Overflow, never a wrong number", "[least-squares]") { - constexpr auto fifteen = formula::linear_least_squares( - formula::curve(formula::series, formula::series), { .reference = "Example Standard 12" }); + constexpr auto twentySeven = formula::linear_least_squares( + formula::curve(formula::series, formula::series), { .reference = "Example Standard 12" }); auto const overflowing = - formula::checked_evaluate(formula::opaque_output<"slope">(fifteen), distinct_denominators<15>()); + formula::checked_evaluate(formula::opaque_output<"slope">(twentySeven), distinct_denominators<27>()); REQUIRE(!overflowing.has_value()); CHECK(overflowing.error() == formula::ArithmeticError::Overflow); formula::Trace<> recorded {}; (void) formula::detail::dispatch( - formula::opaque_output<"slope">(fifteen), distinct_denominators<15>(), formula::RecordingSink { recorded }); + formula::opaque_output<"slope">(twentySeven), distinct_denominators<27>(), formula::RecordingSink { recorded }); REQUIRE(formula::opaque_data(recorded, 3) != nullptr); CHECK(formula::opaque_data(recorded, 3)->failure == formula::OpaqueFailure::Own); @@ -266,6 +266,16 @@ TEST_CASE("a fit that exceeds Rational's range says Overflow, never a wrong numb constexpr auto five = formula::linear_least_squares( formula::curve(formula::series, formula::series), { .reference = "Example Standard 12" }); CHECK(formula::checked_evaluate(formula::opaque_output<"slope">(five), distinct_denominators<5>()).has_value()); + + // Fifteen points, which overflowed 64 bits, fit: a slope of + // 1589556185454870/13675373506169 mm/min. + constexpr auto fifteen = formula::linear_least_squares( + formula::curve(formula::series, formula::series), { .reference = "Example Standard 12" }); + auto const fitted = + formula::checked_evaluate(formula::opaque_output<"slope">(fifteen), distinct_denominators<15>()); + REQUIRE(fitted.has_value()); + REQUIRE(fitted->is_value()); + CHECK(fitted->measurement().value() == rat(1'589'556'185'454'870, 13'675'373'506'169)); } TEST_CASE("least squares works in double, to within the representation", "[least-squares]") @@ -865,33 +875,33 @@ TEST_CASE("rounded output: wherever the exact fit answers the rounded fit is it TEST_CASE("rounded output: the rounded fit answers where the exact fit overflows", "[least-squares]") { - constexpr auto fifteen = formula::linear_least_squares( - formula::curve(formula::series, formula::series), { .reference = "Example Standard 12" }); + constexpr auto twentySeven = formula::linear_least_squares( + formula::curve(formula::series, formula::series), { .reference = "Example Standard 12" }); auto const exactRoute = - formula::checked_evaluate(formula::opaque_output<"slope">(fifteen), distinct_denominators<15>()); + formula::checked_evaluate(formula::opaque_output<"slope">(twentySeven), distinct_denominators<27>()); REQUIRE(!exactRoute.has_value()); CHECK(exactRoute.error() == formula::ArithmeticError::Overflow); - // 1.93724895... mm/s: 1.9372 at 4 dp, 116.232 mm/min; 1.9373 upwards. + // 2.03730427... mm/s: 2.0373 at 4 dp, 122.238 mm/min; 2.0374 upwards. constexpr auto evenSlope = formula::rounded_output<"slope", MillimetrePerSecond, formula::DecimalPlaces { 4 }, formula::RoundingMode::HalfEven>( - fifteen); - auto const even = formula::checked_evaluate(evenSlope, distinct_denominators<15>()); + twentySeven); + auto const even = formula::checked_evaluate(evenSlope, distinct_denominators<27>()); REQUIRE(even.has_value()); - CHECK(even->measurement().value() == rat(14529, 125)); + CHECK(even->measurement().value() == rat(61119, 500)); auto const upwards = formula::checked_evaluate_si( formula::rounded_output<"slope", MillimetrePerSecond, formula::DecimalPlaces { 4 }, formula::RoundingMode::Ceiling>( - fifteen), - distinct_denominators<15>()); + twentySeven), + distinct_denominators<27>()); REQUIRE(upwards.has_value()); REQUIRE(upwards->has_value()); - CHECK(**upwards == rat(19373, 10'000'000)); - // A negative intercept, -0.017688... mm: the sign decides the directed modes. - auto const draws = distinct_denominators<15>(); - CHECK(rounded_intercept(fifteen, draws) == rat(-177, 10'000'000)); - CHECK(rounded_intercept(fifteen, draws) == rat(-177, 10'000'000)); - CHECK(rounded_intercept(fifteen, draws) == rat(-177, 10'000'000)); - CHECK(rounded_intercept(fifteen, draws) == rat(-11, 625'000)); - CHECK(rounded_intercept(fifteen, draws) == rat(-11, 625'000)); + CHECK(**upwards == rat(10187, 5'000'000)); + // A negative intercept, -0.0899495... mm: the sign decides the directed modes. + auto const draws = distinct_denominators<27>(); + CHECK(rounded_intercept(twentySeven, draws) == rat(-899, 10'000'000)); + CHECK(rounded_intercept(twentySeven, draws) == rat(-9, 100'000)); + CHECK(rounded_intercept(twentySeven, draws) == rat(-9, 100'000)); + CHECK(rounded_intercept(twentySeven, draws) == rat(-899, 10'000'000)); + CHECK(rounded_intercept(twentySeven, draws) == rat(-899, 10'000'000)); // Under the approximate style other lines carry the marker; the rounded line does not. formula::Trace<> recorded {}; @@ -900,7 +910,7 @@ TEST_CASE("rounded output: the rounded fit answers where the exact fit overflows recorded, { .maxSteps = 100, .numbers = formula::NumberStyle::approximate_decimal(formula::RoundingMode::HalfEven) }); CHECK(approximate.find("\xe2\x89\x88") != std::string::npos); - CHECK(approximate.find("5. round(slope of #4, to 4 dp of mm/s) = 1.9372 mm/s [nearest, ties to even]\n") + CHECK(approximate.find("5. round(slope of #4, to 4 dp of mm/s) = 2.0373 mm/s [nearest, ties to even]\n") != std::string::npos); } @@ -1270,17 +1280,21 @@ TEST_CASE("a line through observations in double agrees with the exact line", "[ namespace { // Fifty readings at four decimals: t_k = k + 1 + (7919 k mod 997) / 10^4 s, -// L_k = 2410 + 3.17 k + ((3217 k mod 1009) - 504) / 10^4 mm. Reference values -// computed with Python's fractions. -[[nodiscard]] auto fifty_readings() +// L_k = 2410 + 3.17 k + ((3217 k mod 1009) - 504) / 10^4 mm. At eight, each +// gains (1237 k mod 10^4) / 10^8 s and (4111 k mod 10^4) / 10^8 mm. +// Reference values computed with Python's fractions. +[[nodiscard]] auto fifty_readings(std::int64_t const morePlaces = 1) { std::array times; std::array lengths; for (std::size_t at = 0; at < 50; ++at) { auto const position = static_cast(at); - times[at] = rat(10'000 * (position + 1) + (7919 * position) % 997, 10'000); - lengths[at] = rat(24'100'000 + 31'700 * position + (3217 * position) % 1009 - 504, 10'000); + times[at] = rat((10'000 * (position + 1) + (7919 * position) % 997) * morePlaces + (1237 * position) % morePlaces, + 10'000 * morePlaces); + lengths[at] = rat((24'100'000 + 31'700 * position + (3217 * position) % 1009 - 504) * morePlaces + + (4111 * position) % morePlaces, + 10'000 * morePlaces); } return formula::environment(*formula::MeasuredObservations::from(times), *formula::MeasuredObservations::from(lengths)); @@ -1290,24 +1304,66 @@ constexpr auto fiftyFit = formula::linear_least_squares( formula::observations, formula::observations, { .reference = "Example Standard 12" }); } // namespace -TEST_CASE("a line through fifty readings at four decimals overflows exactly and answers rounded", +TEST_CASE("a line through fifty readings at eight decimals overflows exactly and answers rounded", "[least-squares][observations]") { auto const fifty = fifty_readings(); - // Exactly: the slope alone would fit a Rational (46 and 54 bits), but the - // intercept's numerator needs 64 bits and R^2 92 -- a call's outputs answer - // or fail together, the operation's own Overflow. - auto const exact = formula::checked_evaluate(formula::opaque_output<"slope">(fiftyFit), fifty); + auto const fiftyAtEight = fifty_readings(10'000); + // Exactly, at eight decimals: R^2 needs 130 bits -- a call's outputs + // answer or fail together, the operation's own Overflow. + auto const exact = formula::checked_evaluate(formula::opaque_output<"slope">(fiftyFit), fiftyAtEight); REQUIRE(!exact.has_value()); CHECK(exact.error() == formula::ArithmeticError::Overflow); formula::Trace<> recorded {}; (void) formula::detail::dispatch( - formula::opaque_output<"slope">(fiftyFit), fifty, formula::RecordingSink { recorded }); + formula::opaque_output<"slope">(fiftyFit), fiftyAtEight, formula::RecordingSink { recorded }); REQUIRE(formula::opaque_data(recorded, 2) != nullptr); CHECK(formula::opaque_data(recorded, 2)->failure == formula::OpaqueFailure::Own); - // Rounded where used: 3.170694... mm/s is 3.1707 (floor would give - // 3.1706), 2406.645493... mm is 2406.6455, and R^2 = 0.999996568... - // floored at 6 dp is 0.999996 (to nearest, 0.999997). + // At four decimals, which overflowed 64 bits (the intercept's numerator + // needs 64, R^2 92), the line is exact. + auto const exactSlope = + formula::checked_evaluate_si(formula::opaque_output<"slope">(fiftyFit), fifty); + auto const exactIntercept = + formula::checked_evaluate(formula::opaque_output<"intercept">(fiftyFit), fifty); + auto const exactQuality = + formula::checked_evaluate(formula::opaque_output<"r squared">(fiftyFit), fifty); + REQUIRE(exactSlope.has_value()); + REQUIRE(exactSlope->has_value()); + REQUIRE(exactIntercept.has_value()); + REQUIRE(exactQuality.has_value()); + CHECK(**exactSlope == rat(55'003'588'021'027, 17'347'486'551'727'000)); + CHECK(exactIntercept->measurement().value() + == formula::Rational { formula::Rational::Int { 10'437'312'581'974'968'367ULL }, 4'336'871'637'931'750 }); + CHECK(exactQuality->measurement().value() + == formula::Rational { formula::Rational::Int { 3'025'394'695'186'864'890 } * 1'000'000'000 + 194'134'729, + formula::Rational::Int { 3'025'405'075'415'899'033 } * 1'000'000'000 + 563'344'129 }); + // Rounded where used, at eight decimals, where the exact line overflows: + // 3.17069437... mm/s is 3.1707 (floor would give 3.1706), + // 2406.645395... mm is 2406.6454, and R^2 = 0.99999656893... floored at + // 6 dp is 0.999996 (to nearest, 0.999997). + auto const slopeAtEight = formula::checked_evaluate( + formula::rounded_output<"slope", MillimetrePerSecond, formula::DecimalPlaces { 4 }, formula::RoundingMode::HalfEven>( + fiftyFit), + fiftyAtEight); + REQUIRE(slopeAtEight.has_value()); + CHECK(slopeAtEight->measurement().value() == rat(31707, 10'000)); + auto const interceptAtEight = formula::checked_evaluate( + formula:: + rounded_output<"intercept", unit::Millimetre, formula::DecimalPlaces { 4 }, formula::RoundingMode::HalfEven>( + fiftyFit), + fiftyAtEight); + REQUIRE(interceptAtEight.has_value()); + CHECK(interceptAtEight->measurement().value() == rat(12033227, 5000)); + auto const fitQualityAtEight = formula::checked_evaluate( + formula::rounded_output<"r squared", unit::One, formula::DecimalPlaces { 6 }, formula::RoundingMode::Floor>( + fiftyFit), + fiftyAtEight); + REQUIRE(fitQualityAtEight.has_value()); + CHECK(fitQualityAtEight->measurement().value() == rat(249999, 250'000)); + + // At four decimals the rounded route rounds the exact line above: + // 3.170694... mm/s is 3.1707, 2406.645493... mm is 2406.6455, and R^2 = + // 0.999996568... floored at 6 dp is 0.999996. auto const slope = formula::checked_evaluate( formula::rounded_output<"slope", MillimetrePerSecond, formula::DecimalPlaces { 4 }, formula::RoundingMode::HalfEven>( fiftyFit), @@ -1331,12 +1387,14 @@ TEST_CASE("a line through fifty readings at four decimals overflows exactly and TEST_CASE("an observation that fails to convert fails the fit at that observation", "[least-squares][observations][trace]") { - // 1.03 x 10^17 km is 1.03 x 10^20 m: the third length. The fit relays it. + // 1.03 x 10^36 km is 1.03 x 10^39 m: the third length. The fit relays it. constexpr auto farFit = formula::linear_least_squares( formula::observations, formula::observations, { .reference = "Example Standard 12" }); - constexpr auto far = formula::environment( - formula::MeasuredObservations(rat(1), rat(2), rat(4), rat(7)), - formula::MeasuredObservations(rat(103), rat(127), rat(103'000'000'000'000'000), rat(163))); + constexpr formula::Rational farthest { formula::Rational::Int { 1'030'000'000'000'000'000 } + * 1'000'000'000'000'000'000 }; + constexpr auto far = + formula::environment(formula::MeasuredObservations(rat(1), rat(2), rat(4), rat(7)), + formula::MeasuredObservations(rat(103), rat(127), farthest, rat(163))); formula::Trace<> recorded {}; (void) formula::detail::dispatch( formula::opaque_output<"slope">(farFit), far, formula::RecordingSink { recorded }); diff --git a/test/lookup_tests.cpp b/test/lookup_tests.cpp index 06f5d85a..97ce4ed2 100644 --- a/test/lookup_tests.cpp +++ b/test/lookup_tests.cpp @@ -1040,9 +1040,13 @@ inline constexpr BreakpointTable<2> SteepKeyRange { breakpoint(0), breakpoint(40 /// the two above. inline constexpr BreakpointTable<2> UnrepresentableAnswer { breakpoint(0), breakpoint(4) }; -/// `2^62`, an ordinary representable `Rational`, used as a row value where the +/// `2^126`, an ordinary representable `Rational`, used as a row value where the /// scale rather than the arithmetic is the point. -constexpr std::int64_t Huge = std::int64_t { 1 } << 62; +constexpr formula::Rational::Int Huge = formula::Rational::Int { 1 } << 126; + +/// `2^62`, the same scale for a `Rational` of 64 bits, which these tables +/// once overflowed at. +constexpr std::int64_t Huge64 = std::int64_t { 1 } << 62; } // namespace TEST_CASE("an unevenly spaced curve interpolates against the bracketing pair, not the first one", "[lookup]") @@ -1084,20 +1088,20 @@ TEST_CASE("the interpolation divides the span out before multiplying the rise in // // Keys on a common grid (0 and 10) and a value at the top of `Rational`'s // range. Dividing first cancels `5/10` to the weight `1/2` before the value - // is ever touched, and answers 2^61 exactly. Multiplying first forms - // `5 * 2^62`, the one product that mixes key magnitude with value + // is ever touched, and answers 2^125 exactly. Multiplying first forms + // `5 * 2^126`, the one product that mixes key magnitude with value // magnitude, and reports Overflow -- for a table whose exact answer is a // plain integer. // // Common-grid keys are what published curves actually have, which is why // this direction was chosen. The other direction exists and is asserted in // the test just below. - constexpr auto node = - interpolating_lookup(var, { rat(0), rat(Huge) }); + constexpr auto node = interpolating_lookup( + var, { rat(0), formula::Rational { Huge } }); constexpr auto computed = formula::checked_evaluate(node, millimetresOfDiameter(5)); STATIC_REQUIRE(computed.has_value()); STATIC_REQUIRE(computed->is_value()); - STATIC_REQUIRE(computed->measurement().value() == rat(Huge / 2)); + STATIC_REQUIRE(computed->measurement().value() == formula::Rational { Huge / 2 }); } TEST_CASE("dividing the span out first is a trade-off, and this is the table it loses on", "[lookup]") @@ -1105,9 +1109,9 @@ TEST_CASE("dividing the span out first is a trade-off, and this is the table it // The honest other half of the test above: neither order dominates, and a // comment saying so is worth less than a table saying so. // - // Keys 0 and 4e9 with a probe at 1/4e9 mm: the weight `(1/4e9) / 4e9` + // Keys 0 and 4e9 with a probe at 2^-100 mm: the weight `2^-100 / 4e9` // cannot cancel, and forming it overflows -- where multiplying first would - // have cancelled the offset against the rise and answered 1/4e9 exactly. + // have cancelled the offset against the rise and answered 2^-100 exactly. // The library refuses rather than approximating, which is the property that // matters; that it refuses here at all is the price of the order chosen // above. @@ -1117,9 +1121,18 @@ TEST_CASE("dividing the span out first is a trade-off, and this is the table it // where to look. constexpr auto node = interpolating_lookup( var, { rat(0), rat(4000000000) }); - constexpr auto computed = formula::checked_evaluate(node, millimetresOfDiameter(1, 4000000000)); + constexpr auto computed = formula::checked_evaluate( + node, + formula::environment(formula::Measured { formula::Rational { 1, formula::Rational::Int { 1 } << 100 } })); STATIC_REQUIRE(!computed.has_value()); STATIC_REQUIRE(computed.error() == formula::ArithmeticError::Overflow); + + // At a probe of 1/4e9 mm the weight's denominator, 1.6e19, is past 64 + // bits but inside 128, and the answer is exact. + constexpr auto finer = formula::checked_evaluate(node, millimetresOfDiameter(1, 4000000000)); + STATIC_REQUIRE(finer.has_value()); + STATIC_REQUIRE(finer->is_value()); + STATIC_REQUIRE(finer->measurement().value() == rat(1, 4000000000)); } TEST_CASE("an interpolation whose exact answer is not representable is reported, never rounded", "[lookup]") @@ -1128,9 +1141,9 @@ TEST_CASE("an interpolation whose exact answer is not representable is reported, // about: where the exact rational the two rows imply does not exist inside // `Rational`, the library says so and hands back nothing. // - // Keys 0 and 4, values 0 and 2^62 - 1 (odd, so nothing cancels), probed at - // 3. The exact answer is 3(2^62 - 1)/4, whose reduced numerator is - // 13835058055282163709 -- above `Rational`'s maximum, so the answer is not + // Keys 0 and 4, values 0 and 2^126 - 1 (odd, so nothing cancels), probed + // at 3. The exact answer is 3(2^126 - 1)/4, whose reduced numerator is + // about 2.55 * 10^38 -- above `Rational`'s maximum, so the answer is not // merely awkward to reach, it does not exist. A representation that rounded // would hand back something near it and say nothing; this reports // `Overflow`, which is the only honest answer. @@ -1140,10 +1153,19 @@ TEST_CASE("an interpolation whose exact answer is not representable is reported, // curve that does not cover the specimen. Both orders of the interpolation // overflow here, so this test says nothing about that choice -- deliberately. constexpr auto node = interpolating_lookup( - var, { rat(0), rat(Huge - 1) }); + var, { rat(0), formula::Rational { Huge - 1 } }); constexpr auto computed = formula::checked_evaluate(node, millimetresOfDiameter(3)); STATIC_REQUIRE(!computed.has_value()); STATIC_REQUIRE(computed.error() == formula::ArithmeticError::Overflow); + + // With 2^62 - 1, whose answer 3(2^62 - 1)/4 overflowed 64 bits, it exists. + constexpr auto node64 = interpolating_lookup( + var, { rat(0), rat(Huge64 - 1) }); + constexpr auto computed64 = formula::checked_evaluate(node64, millimetresOfDiameter(3)); + STATIC_REQUIRE(computed64.has_value()); + STATIC_REQUIRE(computed64->is_value()); + STATIC_REQUIRE(computed64->measurement().value() + == formula::Rational { formula::Rational::Int { Huge64 - 1 } * 3, 4 }); } TEST_CASE("a row hit can still overflow in the result-unit conversion, and says so", "[lookup]") @@ -1154,18 +1176,26 @@ TEST_CASE("a row hit can still overflow in the result-unit conversion, and says // 0 mm sits exactly on the first row, so the interpolation performs no // arithmetic at all and cannot overflow. The value is then converted out of // the node's result unit (kilometres) into the coherent SI unit (metres) -- - // and 2^62 km is a perfectly representable `Rational` that does not survive - // being multiplied by 1000. + // and 2^126 km is a perfectly representable `Rational` that does not + // survive being multiplied by 1000. // // This path is shared with the banded and the exact lookup, which convert // their selected row the same way for the same reason; nothing about it is // particular to interpolation. It is asserted here because this is the file // where the claim was made. constexpr auto node = interpolating_lookup( - var, { rat(Huge), rat(1) }); + var, { formula::Rational { Huge }, rat(1) }); constexpr auto computed = formula::checked_evaluate(node, millimetresOfDiameter(0)); STATIC_REQUIRE(!computed.has_value()); STATIC_REQUIRE(computed.error() == formula::ArithmeticError::Overflow); + + // 2^62 km, which overflowed 64 bits, is 2^62 * 10^6 mm. + constexpr auto node64 = interpolating_lookup( + var, { rat(Huge64), rat(1) }); + constexpr auto computed64 = formula::checked_evaluate(node64, millimetresOfDiameter(0)); + STATIC_REQUIRE(computed64.has_value()); + STATIC_REQUIRE(computed64->is_value()); + STATIC_REQUIRE(computed64->measurement().value() == formula::Rational { formula::Rational::Int { Huge64 } * 1000000 }); } TEST_CASE("breakpoint: a key given as an exact number", "[lookup]") diff --git a/test/measured_tests.cpp b/test/measured_tests.cpp index 78defa0d..e9931007 100644 --- a/test/measured_tests.cpp +++ b/test/measured_tests.cpp @@ -4,6 +4,7 @@ #include +#include #include namespace unit = formula::unit; @@ -310,12 +311,18 @@ TEST_CASE("a conversion overflow is reported as an error, not re-read as absence { // Half of Rational's maximum numerator, in metres. Converting to // millimetres multiplies by 1000, which does not fit. - Measured const huge { *Rational::make(4611686018427387903LL, 1) }; + Measured const huge { Rational { std::numeric_limits::max() / 2 } }; auto const overflowed = formula::checked_convert_to(huge); REQUIRE_FALSE(overflowed.has_value()); CHECK(overflowed.error() == ArithmeticError::Overflow); + // Half of the largest 64-bit integer, which overflowed 64 bits, converts. + Measured const huge64 { Rational { 4611686018427387903LL } }; + auto const converted = formula::checked_convert_to(huge64); + REQUIRE(converted.has_value()); + CHECK(converted->value() == Rational { Rational::Int { 4611686018427387903LL } * 1000 }); + // The present-but-error result must not be confused with an absent one: // checking has_value() on the OUTER expected is the only correct read // here. Dereferencing it without checking first would be exactly the @@ -506,7 +513,7 @@ TEST_CASE("round_to_declared and within_bounds: throwing twins", "[measured]") TEST_CASE("the throwing twins throw the error their checked form returns", "[measured]") { - Measured const huge { *Rational::make(4611686018427387903LL, 1) }; + Measured const huge { Rational { std::numeric_limits::max() / 2 } }; CHECK_THROWS_AS(formula::convert_to(huge), formula::ArithmeticException); Measured const unroundable { *Rational::make(1, 3) }; CHECK_THROWS_AS(formula::round_to_declared(unroundable, formula::RoundingMode::HalfEven), diff --git a/test/multiple_least_squares_tests.cpp b/test/multiple_least_squares_tests.cpp index 1d3fb5bd..f313662f 100644 --- a/test/multiple_least_squares_tests.cpp +++ b/test/multiple_least_squares_tests.cpp @@ -383,7 +383,7 @@ TEST_CASE("eight regressors, the most a fit takes, are fitted exactly, rounded w // Computed with Python's fractions from the columns above. At run time: // ten rows of eight regressors exceed the constant-evaluation limit of // cl 19.51 (1048576 steps) and of g++ 14 (33554432 operations), measured. - constexpr formula::Rational::Int shared = 156'879'508'961; + constexpr std::int64_t shared = 156'879'508'961; CHECK(exact_output(formula::opaque_output<"constant">(eightFactors), tenRows) == rat(11'058'652'968'024, shared)); CHECK(exact_output(formula::opaque_output<"coefficient 1">(eightFactors), tenRows) == rat(758'096'997'271, shared)); CHECK(exact_output(formula::opaque_output<"coefficient 2">(eightFactors), tenRows) == rat(-183'676'518'433, shared)); diff --git a/test/negative/band_bound_out_of_range.cpp b/test/negative/band_bound_out_of_range.cpp new file mode 100644 index 00000000..e70647fd --- /dev/null +++ b/test/negative/band_bound_out_of_range.cpp @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// A Band's bounds are 64-bit pairs, so that it stays a template argument; a +// bound beyond 64 bits fails to compile, naming the guard. +#include +#include + +constexpr formula::Band tooWide = + formula::band(formula::Rational { formula::Int128 { 1 } << 70 }, formula::Rational { formula::Int128 { 1 } << 71 }); + +int main() +{ + return tooWide.lowNumerator == 0 ? 1 : 0; +} diff --git a/test/negative/breakpoint_key_out_of_range.cpp b/test/negative/breakpoint_key_out_of_range.cpp new file mode 100644 index 00000000..a1fbbadf --- /dev/null +++ b/test/negative/breakpoint_key_out_of_range.cpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// A Breakpoint's key is a 64-bit pair, so that it stays a template argument; +// a key beyond 64 bits fails to compile, naming the guard. +#include +#include + +constexpr formula::Breakpoint tooWide = formula::breakpoint(formula::Rational { formula::Int128 { 1 } << 70 }); + +int main() +{ + return tooWide.numerator == 0 ? 1 : 0; +} diff --git a/test/negative/int128_format_spec_not_understood.cpp b/test/negative/int128_format_spec_not_understood.cpp new file mode 100644 index 00000000..e30ea524 --- /dev/null +++ b/test/negative/int128_format_spec_not_understood.cpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// An Int128 is written as its decimal digits; any spec but the empty one is +// refused where the format string is written. +#include + +#include +#include + +std::string probe() +{ + return std::format("{:x}", formula::Int128 { 255 }); +} diff --git a/test/negative/measured_series_element_wide_unsigned.cpp b/test/negative/measured_series_element_wide_unsigned.cpp deleted file mode 100644 index 2c7cae66..00000000 --- a/test/negative/measured_series_element_wide_unsigned.cpp +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// EXPECT: formula: this unsigned type can hold values above Rational's maximum -// -// A series element written as a 64-bit unsigned integer, which can hold values -// `Rational` cannot. `Rational` refuses it in its own words; the series' own -// check stays silent, so the one mistake is one message. -#include - -#include - -struct Retained: formula::Quantity -{ -}; - -inline constexpr auto screens = formula::measured_series(127, std::uint64_t { 139 }); - -int main() -{ - return screens.element(0).has_value() ? 0 : 1; -} diff --git a/test/negative/rational_from_wide_unsigned.cpp b/test/negative/rational_from_wide_unsigned.cpp deleted file mode 100644 index f6cb6c23..00000000 --- a/test/negative/rational_from_wide_unsigned.cpp +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// An unsigned type as wide as Int can hold values above Rational's maximum. -// Letting it convert implicitly made `std::size_t { 1 } << 63` a NEGATIVE -// Rational and SIZE_MAX equal to -1, silently. Every other failure path in this -// library reports rather than lying, so this must not compile. -#include - -#include - -formula::Rational const tooWide = std::size_t { 1 } << 63; - -int main() -{ - return tooWide.is_zero() ? 1 : 0; -} diff --git a/test/number_text_tests.cpp b/test/number_text_tests.cpp index 6f11e3dc..4f4cad6e 100644 --- a/test/number_text_tests.cpp +++ b/test/number_text_tests.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -30,8 +31,11 @@ namespace unit = formula::unit; namespace { -inline constexpr Rational::Int IntMax = formula::detail::IntMax; -inline constexpr Rational::Int IntMin = formula::detail::IntMin; +// The bounds of `Rational::Int`, 2^127 - 1 and -2^127. +inline constexpr Rational::Int IntMax = std::numeric_limits::max(); +inline constexpr Rational::Int IntMin = std::numeric_limits::min(); +// The largest 64-bit integer, which a `Rational` once stopped at. +inline constexpr std::int64_t Int64Max = std::numeric_limits::max(); /// A quantity in kilojoules: a unit with a symbol, `kJ`, and one declared /// decimal, which the padded cases below read. @@ -115,14 +119,15 @@ TEST_CASE("an exact decimal is shown only when it is the exact value", "[number_ TEST_CASE("the extremes of Rational are spelled in full", "[number_text]") { - // The magnitude of IntMin is 2^63, which Int cannot hold; the text has it. - STATIC_REQUIRE(formula::fraction_text(Rational { IntMin }) == "-9223372036854775808"); - STATIC_REQUIRE(formula::fraction_text(Rational { IntMin, IntMax }) == "-9223372036854775808/9223372036854775807"); - STATIC_REQUIRE(*formula::exact_decimal_text(Rational { IntMin }) == "-9223372036854775808"); - STATIC_REQUIRE(*formula::exact_decimal_text(Rational { IntMax }) == "9223372036854775807"); + // The magnitude of IntMin is 2^127, which Int cannot hold; the text has it. + STATIC_REQUIRE(formula::fraction_text(Rational { IntMin }) == "-170141183460469231731687303715884105728"); + STATIC_REQUIRE(formula::fraction_text(Rational { IntMin, IntMax }) + == "-170141183460469231731687303715884105728/170141183460469231731687303715884105727"); + STATIC_REQUIRE(*formula::exact_decimal_text(Rational { IntMin }) == "-170141183460469231731687303715884105728"); + STATIC_REQUIRE(*formula::exact_decimal_text(Rational { IntMax }) == "170141183460469231731687303715884105727"); STATIC_REQUIRE( formula::decimal_text(Rational { IntMin }, DecimalPlaces { 18 }, RoundingMode::HalfEven, DecimalPadding::Padded) - == "-9223372036854775808.000000000000000000"); + == "-170141183460469231731687303715884105728.000000000000000000"); } // ---- rounded decimals ---- @@ -190,19 +195,31 @@ TEST_CASE("a rounded decimal exists where checked_round overflows", "[number_tex { NumberText const third = formula::decimal_text(Rational { IntMax, 3 }, DecimalPlaces { 18 }, RoundingMode::HalfEven, DecimalPadding::Padded); - CHECK(third.view() == "3074457345618258602.333333333333333333"); + CHECK(third.view() == "56713727820156410577229101238628035242.333333333333333333"); CHECK_FALSE(third.is_exact()); auto const rounded = formula::checked_round(Rational { IntMax, 3 }, DecimalPlaces { 18 }, RoundingMode::HalfEven); REQUIRE_FALSE(rounded.has_value()); CHECK(rounded.error() == ArithmeticError::Overflow); + + // The largest 64-bit integer over 3, which overflowed 64 bits, rounds: + // to the value its text shows. + auto const roundedThird64 = + formula::checked_round(Rational { Int64Max, 3 }, DecimalPlaces { 18 }, RoundingMode::HalfEven); + REQUIRE(roundedThird64.has_value()); + CHECK(*roundedThird64 + == Rational { Rational::Int { 3074457345618258602 } * 1000000000000000000 + 333333333333333333, + 1000000000000000000 }); + NumberText const third64 = formula::decimal_text( + Rational { Int64Max, 3 }, DecimalPlaces { 18 }, RoundingMode::HalfEven, DecimalPadding::Padded); + CHECK(third64.view() == "3074457345618258602.333333333333333333"); } TEST_CASE("the long division never forms ten times a remainder near IntMax", "[number_text]") { // A denominator of IntMax leaves a remainder of up to IntMax - 1, and ten - // times that is past 2^64: formed directly, it wraps, and - // (IntMax - 1)/IntMax -- 0.99999999999999999989... -- would read 0.2 to + // times that is past 2^128: formed directly, it wraps, and + // (IntMax - 1)/IntMax -- 0.999..., 38 nines and more -- would read 0.2 to // one place. All four values round, so none of these texts is exact; // Padded keeps every place, the trailing zeros included. Rational const nearOne { IntMax - 1, IntMax }; @@ -294,7 +311,7 @@ TEST_CASE("a rounding mode that is none of the seven is refused on a tie as chec TEST_CASE("decimal_text agrees with checked_round wherever checked_round answers", "[number_text]") { - std::array const divisors { 1, 2, 3, 7, 8, 40, 125, 1000, 1024 }; + std::array const divisors { 1, 2, 3, 7, 8, 40, 125, 1000, 1024 }; std::array const roundingModes { RoundingMode::HalfAwayFromZero, RoundingMode::HalfTowardZero, RoundingMode::HalfEven, RoundingMode::Ceiling, RoundingMode::Floor, RoundingMode::TowardZero, @@ -304,9 +321,9 @@ TEST_CASE("decimal_text agrees with checked_round wherever checked_round answers std::size_t compared = 0; std::size_t disagreements = 0; std::string firstDisagreement; - for (Rational::Int dividend = -2000; dividend <= 2000; ++dividend) + for (std::int64_t dividend = -2000; dividend <= 2000; ++dividend) { - for (Rational::Int const divisor: divisors) + for (std::int64_t const divisor: divisors) { Rational const unrounded { dividend, divisor }; for (std::int32_t places = 0; places <= MostPlaces; ++places) @@ -492,11 +509,20 @@ TEST_CASE("a measured value is its number then its unit's symbol", "[number_text TEST_CASE("the longest text this library spells fits its buffer", "[number_text]") { - // The marker, a sign, 19 whole digits, a point, 18 places, a space and a - // 16-byte symbol: 59 bytes of the 64 a NumberText can hold. - NumberText const widest = formula::number_text(Measured { Rational { IntMin, 3 } }, - NumberStyle::approximate_decimal(RoundingMode::HalfEven)); - CHECK(widest.view() == "\xe2\x89\x88" "-3074457345618258602.666666666666666667 abcdefghijklmnop"); - CHECK(widest.view().size() == 59); + // 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 + // sized for. + NumberText const widest = + formula::number_text(Measured { Rational { IntMin, IntMax } }, NumberStyle::fraction()); + CHECK(widest.view() + == "-170141183460469231731687303715884105728/170141183460469231731687303715884105727 abcdefghijklmnop"); 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. + 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); } diff --git a/test/opaque_tests.cpp b/test/opaque_tests.cpp index 6f531c32..b9cbd063 100644 --- a/test/opaque_tests.cpp +++ b/test/opaque_tests.cpp @@ -367,12 +367,13 @@ TEST_CASE("an input's failure is not hidden behind another input's absence", "[o // once every input has been asked. constexpr auto hugeCall = formula::opaque( { .reference = "Example Standard 12" }, formula::series, formula::var); - // 2^62 t is 2^65 kg: its conversion to the coherent unit overflows. + // 2^126 t is 1000 * 2^126 kg: its conversion to the coherent unit overflows. + constexpr formula::Rational huge { formula::Rational::Int { 1 } << 126 }; auto const overflowing = formula::environment(formula::measured_series(formula::Measured { rat(127) }, formula::Measured::absent(), formula::Measured { rat(191) }, formula::Measured { rat(139) }), - formula::Measured { rat(std::int64_t { 1 } << 62) }); + formula::Measured { huge }); auto const called = formula::detail::evaluate_call(hugeCall, overflowing, formula::NullSink {}); REQUIRE(!called.has_value()); CHECK(called.error().error == formula::ArithmeticError::Overflow); @@ -715,13 +716,15 @@ consteval std::size_t field_count() TEST_CASE("opaque trace data lives in side tables, and a Step has no more fields for it", "[opaque][trace]") { // Counted, not measured: Step had 45 fields at the branch point, 9d3cdd4, - // and has 47 since a binary step says which side each operand stood on - // (`leftOperand`, `rightOperand`), which no opaque step uses. A byte-sized + // 47 since a binary step says which side each operand stood on + // (`leftOperand`, `rightOperand`), and has 48 since a sample-size lookup + // keeps the high word of a count past 64 bits (`lookupKeyHigh`), none of + // which an opaque step uses. A byte-sized // field -- an enum or a flag, the likeliest slip for an opaque step's // failure or a retry's -- can land in padding and leave sizeof unchanged // (it did, on g++-14 and on libc++); it cannot leave the count unchanged. // Any field added to Step, on any library, fails this. - STATIC_REQUIRE(field_probe::field_count>() == 47); + STATIC_REQUIRE(field_probe::field_count>() == 48); } namespace { @@ -1541,13 +1544,15 @@ TEST_CASE("an overlay and a level walk reach inside a call over observations", " TEST_CASE("an observation that fails to convert fails the call at that observation", "[opaque][observations][trace]") { - // 1.03 x 10^17 km is 1.03 x 10^20 m, past Rational's range: the third + // 1.03 x 10^36 km is 1.03 x 10^39 m, past Rational's range: the third // observation. Relayed, not the operation's own, and counted as an // observation -- never "at element 3". constexpr auto lowestFar = formula::opaque({ .reference = "Example Standard 12" }, formula::observations); - constexpr auto overflowing = - formula::environment(formula::MeasuredObservations(rat(103), rat(127), rat(103'000'000'000'000'000))); + constexpr formula::Rational::Int farthest = + formula::Rational::Int { 1'030'000'000'000'000'000 } * 1'000'000'000'000'000'000; + constexpr auto overflowing = formula::environment( + formula::MeasuredObservations(rat(103), rat(127), formula::Rational { farthest })); constexpr auto called = formula::detail::evaluate_call(lowestFar, overflowing, formula::NullSink {}); STATIC_REQUIRE(!called.has_value()); STATIC_REQUIRE(called.error().error == formula::ArithmeticError::Overflow); @@ -1555,6 +1560,15 @@ TEST_CASE("an observation that fails to convert fails the call at that observati STATIC_REQUIRE(called.error().element == std::optional { 2 }); STATIC_REQUIRE(called.error().site == formula::FailureSite::InputObservation); + // 1.03 x 10^17 km, which overflowed 64 bits, is 1.03 x 10^20 m, and the + // lowest is 103 km. + constexpr auto fitting = + formula::environment(formula::MeasuredObservations(rat(103), rat(127), rat(103'000'000'000'000'000))); + constexpr auto answered = formula::detail::evaluate_call(lowestFar, fitting, formula::NullSink {}); + STATIC_REQUIRE(answered.has_value()); + STATIC_REQUIRE(answered->has_value()); + STATIC_REQUIRE((**answered)[0] == rat(103'000)); + formula::Trace<> recorded {}; (void) formula::detail::dispatch( formula::opaque_output<"lowest">(lowestFar), overflowing, formula::RecordingSink { recorded }); diff --git a/test/overflow_census_tests.cpp b/test/overflow_census_tests.cpp index 7e541373..6424d133 100644 --- a/test/overflow_census_tests.cpp +++ b/test/overflow_census_tests.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // -// The overflow census: how many of the 63 bits of `Rational`'s std::int64_t +// The overflow census: how many of the 127 bits of `Rational`'s 128-bit // numerator and denominator real formulas use. Built as its own program, with // FORMULA_OVERFLOW_CENSUS defined, so that every integer the library's // arithmetic forms at run time is told to the tally (`census_tally.hpp`). @@ -43,7 +43,7 @@ using formula::detail::CensusRole; } /// What one evaluation used: each role's largest magnitude, in bits, and the -/// headroom left of 63 by the largest signed one. +/// headroom left of 127 by the largest signed one. struct Used { int numeratorBits; @@ -53,7 +53,7 @@ struct Used [[nodiscard]] int headroom() const noexcept { - return 63 - std::min(63, std::max({ numeratorBits, denominatorBits, intermediateBits })); + return 127 - std::min(127, std::max({ numeratorBits, denominatorBits, intermediateBits })); } }; @@ -236,7 +236,7 @@ std::array const twentyMasses { }; // Six masses at 6 decimal places of g -- microgram -// resolution. Their variance overflows. +// resolution. Their variance overflowed 64 bits. std::array const sixAtMicrograms { rat(40053270, 1000000), rat(39475922, 1000000), rat(39025798, 1000000), rat(40615904, 1000000), rat(39418416, 1000000), rat(40131659, 1000000) }; @@ -298,7 +298,7 @@ template [[nodiscard]] Survey survey(int samples, Evaluate&& evaluate) { Draws draws { 20260926 }; - Survey found { 0, 63 }; + Survey found { 0, 127 }; for (int drawn = 0; drawn < samples; ++drawn) { std::array sample {}; @@ -369,7 +369,7 @@ template struct FitScan { std::vector overflowing; - int leastHeadroom = 63; + int leastHeadroom = 127; [[nodiscard]] std::string row(char const* label) const { @@ -405,17 +405,17 @@ struct FitLength: formula::Quantity -[[nodiscard]] bool fit_node_overflows() +template +[[nodiscard]] bool fit_node_overflows(Shape shape) { std::array, N> times; std::array, N> forces; for (std::size_t at = 0; at < N; ++at) { - FitPoint const point = three_decimals_point(static_cast(at)); + FitPoint const point = shape(static_cast(at)); times[at] = formula::Measured { point.x }; forces[at] = formula::Measured { point.y }; } @@ -667,40 +667,41 @@ struct TwoRegressorRoutes // ---- The instrument's own control ------------------------------------------------- -TEST_CASE("the census reports 0 bits of headroom for INT64_MAX, and Overflow one step further", "[census]") +TEST_CASE("the census reports 0 bits of headroom for the largest Int128, and Overflow one step further", "[census]") { - constexpr std::int64_t largest = std::numeric_limits::max(); - // (2^62 - 1) + 2^62 = 2^63 - 1 from two 62- and 63-bit operands, built - // outside the count: the sum's 63 bits are the addition's own + constexpr formula::Int128 largest = std::numeric_limits::max(); + // (2^126 - 1) + 2^126 = 2^127 - 1 from two 126- and 127-bit operands, + // built outside the count: the sum's 127 bits are the addition's own // intermediate, which only add_checked_or_none's hook reports. - Rational const lowHalf = rat((std::int64_t { 1 } << 62) - 1); - Rational const highHalf = rat(std::int64_t { 1 } << 62); - Used const atTheLimit = census_of([&] { REQUIRE(formula::checked_add(lowHalf, highHalf).value() == rat(largest)); }); + Rational const lowHalf { (formula::Int128 { 1 } << 126) - 1 }; + Rational const highHalf { formula::Int128 { 1 } << 126 }; + Used const atTheLimit = + census_of([&] { REQUIRE(formula::checked_add(lowHalf, highHalf).value() == Rational { largest }); }); CHECK(atTheLimit.headroom() == 0); - CHECK(atTheLimit.intermediateBits == 63); - // 2^31 * 2^30 = 2^61, one bit short of using all 63: the product is - // mul_checked_or_none's intermediate, from operands of 32 and 31 bits. - Rational const factorA = rat(std::int64_t { 1 } << 31); - Rational const factorB = rat(std::int64_t { 1 } << 30); - Used const oneShort = - census_of([&] { REQUIRE(formula::checked_mul(factorA, factorB).value() == rat(std::int64_t { 1 } << 61)); }); + CHECK(atTheLimit.intermediateBits == 127); + // 2^63 * 2^62 = 2^125, one bit short of using all 127: the product is + // mul_checked_or_none's intermediate, from operands of 64 and 63 bits. + Rational const factorA { formula::Int128 { 1 } << 63 }; + Rational const factorB { formula::Int128 { 1 } << 62 }; + Used const oneShort = census_of( + [&] { REQUIRE(formula::checked_mul(factorA, factorB).value() == Rational { formula::Int128 { 1 } << 125 }); }); CHECK(oneShort.headroom() == 1); - CHECK(oneShort.intermediateBits == 62); + CHECK(oneShort.intermediateBits == 126); // One step further is the library's Overflow, never a figure: a product - // that overflows leaves the count with its operands' 33 bits at most. - CHECK(formula::checked_add(rat(largest), rat(1)).error() == formula::ArithmeticError::Overflow); - Rational const tooWideA = rat(std::int64_t { 1 } << 32); - Rational const tooWideB = rat(std::int64_t { 1 } << 31); - Used const overflowed = - census_of([&] { REQUIRE(formula::checked_mul(tooWideA, tooWideB).error() == formula::ArithmeticError::Overflow); }); - CHECK(overflowed.intermediateBits <= 33); - CHECK(overflowed.numeratorBits <= 33); + // that overflows leaves the count with its operands' 65 bits at most. + CHECK(formula::checked_add(Rational { largest }, rat(1)).error() == formula::ArithmeticError::Overflow); + Rational const tooWideA { formula::Int128 { 1 } << 64 }; + Rational const tooWideB { formula::Int128 { 1 } << 63 }; + Used const overflowed = census_of( + [&] { REQUIRE(formula::checked_mul(tooWideA, tooWideB).error() == formula::ArithmeticError::Overflow); }); + CHECK(overflowed.intermediateBits <= 65); + CHECK(overflowed.numeratorBits <= 65); // A constant evaluation tells the census nothing. Used const constant = census_of([] { - constexpr auto sum = formula::checked_add(Rational { 1 << 20 }, Rational { 1 << 20 }); - static_assert(sum.has_value()); + constexpr auto added = formula::checked_add(Rational { 1 << 20 }, Rational { 1 << 20 }); + static_assert(added.has_value()); }); - CHECK(constant.headroom() == 63); + CHECK(constant.headroom() == 127); } // ---- The census set ----------------------------------------------------------------- @@ -774,13 +775,16 @@ TEST_CASE("census: the norm-shaped cases", "[census]") Used const spreadAtThree = census_of([] { REQUIRE(spread_of<3>(twentyMasses)); }); print_row("20 masses at 3 dp: spread at 3 dp", spreadAtThree); - // The named realistic case: the six masses at micrograms overflow. + // The named realistic case: six masses at micrograms, which overflowed 64 + // bits in kg^2, now answer exactly. auto const named = formula::checked_evaluate(formula::sample_variance(formula::series), series_environment(sixAtMicrograms)); - CHECK(named.error() == formula::ArithmeticError::Overflow); + REQUIRE(named.has_value()); + REQUIRE(named->is_value()); + CHECK(named->measurement().value() == Rational { 2026588050217, 6000000000000 }); auto const namedRejection = formula::checked_evaluate_rejection(rejection_of<6>(sevenQuarters), series_environment(sixAtMicrograms)); - CHECK(namedRejection.error().error == formula::ArithmeticError::Overflow); + CHECK(namedRejection.has_value()); emit("resolution", "| formula | resolution | overflowed | least headroom |"); emit("resolution", "|---|---|---|---|"); @@ -822,21 +826,23 @@ TEST_CASE("census: the norm-shaped cases", "[census]") TEST_CASE("the norm-shaped cases keep the headroom they were measured with, less 4 bits", "[census]") { - // Measured on cl 19.51 at the commit that added these pins: 18, 37 and 36 - // bits of headroom, and fixture A's 3 dp spread forming 20-bit numerators - // and 26 of rounded_sqrt's 64 unsigned bits. A change that quietly spends - // more fails here, not in a user's formula. Measured at that commit: - // without checked_mul's cross-reduction the 64-point curve falls to 23 - // bits, the 3 dp spread's numerators grow to 29 bits, and the control - // below reads 42; with checked_add scaling a sum by the product of the - // denominators rather than their least common multiple, the twenty - // masses' variance fails to evaluate. + // Measured on cl 19.51 at the commit that stores `Rational` in 128 bits: + // 82, 101 and 100 bits of headroom, and fixture A's 3 dp spread forming + // 20-bit numerators and 26 of rounded_sqrt's 128 unsigned bits. A change + // that quietly spends more fails here, not in a user's formula. Measured + // when these pins were added: without checked_mul's cross-reduction the + // 64-point curve uses 40 bits rather than 26, the 3 dp spread's + // numerators grow to 29 bits, and the control below reads 42; with + // checked_add scaling a sum by the product of the denominators rather + // than their least common multiple, the twenty masses' variance failed + // to evaluate at 64 bits; at 128 bits it evaluates, and the headroom + // check below is what fails. Used const twenty = census_of([] { REQUIRE(dispersion_of(twentyMasses)); }); - CHECK(twenty.headroom() >= 18 - 4); + CHECK(twenty.headroom() >= 82 - 4); Used const curve = census_of([] { REQUIRE(grading_curve_read()); }); - CHECK(curve.headroom() >= 37 - 4); + CHECK(curve.headroom() >= 101 - 4); Used const spreadAtThree = census_of([] { REQUIRE(spread_of<3>(fixtureA)); }); - CHECK(spreadAtThree.headroom() >= 36 - 4); + CHECK(spreadAtThree.headroom() >= 100 - 4); CHECK(spreadAtThree.numeratorBits <= 20 + 4); CHECK(spreadAtThree.unsignedBits <= 26 + 4); } @@ -864,7 +870,7 @@ TEST_CASE("census: a cylinder's cross-section and its strength, for d from 101 t { std::vector overflowing; std::vector refusedAt; - int leastHeadroom = 63; + int leastHeadroom = 127; void add(std::int64_t millimetres, std::string const& step) { @@ -923,15 +929,21 @@ TEST_CASE("census: a cylinder's cross-section and its strength, for d from 101 t emit("cylinder", "|---|---|---|---|"); emit("cylinder", area.row("area, pi * d^2 / 4 (the expressions example)")); emit("cylinder", strength.row("strength, 4F / (pi * d^2), F = 89.3 kN, in MPa (the methods example's cylinder)")); - // What the page says, pinned: the area never overflows and keeps its - // measured 15 bits less the census's usual 4; the strength overflows at - // exactly these diameters, 139 mm among them and 135 mm not, and always - // at the division. + // What the page says, pinned: neither overflows at any diameter, and each + // keeps its measured headroom, 79 and 63 bits, less the census's usual 4. + // At 139 mm, which overflowed 64 bits at the division, the strength is + // exact. CHECK(area.overflowing.empty()); - CHECK(area.leastHeadroom >= 15 - 4); - CHECK(strength.overflowing - == std::vector { 101, 103, 107, 109, 113, 119, 121, 127, 131, 137, 139, 143, 149, 151, 157, 161, 163 }); - CHECK(strength.refusedAt == std::vector { "4F / (pi * d^2)" }); + CHECK(area.leastHeadroom >= 79 - 4); + CHECK(strength.overflowing.empty()); + CHECK(strength.refusedAt.empty()); + CHECK(strength.leastHeadroom >= 63 - 4); + auto const at139 = formula::checked_evaluate( + cylinderStrength, + formula::environment(formula::Measured { rat(139) }, formula::Measured { rat(89'300) })); + REQUIRE(at139.has_value()); + REQUIRE(at139->is_value()); + CHECK(at139->measurement().value() == Rational { 13976660729400, 2375042831981 }); } TEST_CASE("census: least squares over 2 to 128 points", "[census]") @@ -941,8 +953,8 @@ TEST_CASE("census: least squares over 2 to 128 points", "[census]") // Overflow, never a line. // The least-squares tests' fixtures, through the node as a method states the fit: t = 1, // 2, 4, 7 s against L = 10.2, 10.9, 12.1, 14.3 mm, whose lengths are - // converted to metres first; and five distinct denominators, the size - // below the fifteen that overflow. + // converted to metres first; and five distinct denominators, well below + // the twenty-eight that overflow. print_row( "least squares, the 4-point fixture: slope and intercept", census_of([] { auto const inputs = @@ -982,18 +994,18 @@ TEST_CASE("census: least squares over 2 to 128 points", "[census]") roundedDistinct.row("the slope rounded to 4 dp by rounded_output: " "a different denominator on every point (stress control)")); - // What the page says, pinned: the spike's first failing sizes, 34 and 15 - // points, hold through the library's fit, and one decimal place never - // overflows. The node agrees with the fit it calls on both sides of 34. + // What the page says, pinned: readings at one and three decimal places + // never overflow, up to 128 points; a different denominator on every + // point does from 28 points, at every size after. The node agrees with + // the fit it calls on both sides of 28. CHECK(oneDecimal.overflowing.empty()); - REQUIRE(!threeDecimals.overflowing.empty()); - CHECK(threeDecimals.overflowing.front() == 34); - CHECK(threeDecimals.overflowing.size() == 57); + CHECK(threeDecimals.overflowing.empty()); REQUIRE(!distinct.overflowing.empty()); - CHECK(distinct.overflowing.front() == 15); - CHECK(distinct.overflowing.size() == 114); - CHECK(!fit_node_overflows<33>()); - CHECK(fit_node_overflows<34>()); + CHECK(distinct.overflowing.front() == 28); + CHECK(distinct.overflowing.size() == 101); + CHECK(!fit_node_overflows<128>(three_decimals_point)); + CHECK(!fit_node_overflows<27>(distinct_denominators_point)); + CHECK(fit_node_overflows<28>(distinct_denominators_point)); // The rounded route, measured with this algorithm: the realistic readings // never outgrow 256 bits; a different denominator on every point does, @@ -1044,19 +1056,19 @@ TEST_CASE("census: a line through observations, exact and rounded, over 2 to 128 emit("regression", twoRegressors.row("two regressors: readings at 3 dp and a temperature at 1 dp in degrees Celsius (realistic)")); - // What the page says, pinned: the realistic rows never stop on the - // rounded route; the exact route stops early. + // What the page says, pinned: the realistic rows stop on neither route; + // a different denominator on every point stops the exact route at 22 + // points and the rounded one at 62. CHECK(threeDecimals.roundedOverflowing.empty()); CHECK(fourDecimals.roundedOverflowing.empty()); - REQUIRE(!threeDecimals.exactOverflowing.empty()); - CHECK(threeDecimals.exactOverflowing.front() == 29); - REQUIRE(!fourDecimals.exactOverflowing.empty()); - CHECK(fourDecimals.exactOverflowing.front() == 7); + CHECK(threeDecimals.exactOverflowing.empty()); + CHECK(fourDecimals.exactOverflowing.empty()); + REQUIRE(!distinct.exactOverflowing.empty()); + CHECK(distinct.exactOverflowing.front() == 22); REQUIRE(!distinct.roundedOverflowing.empty()); CHECK(distinct.roundedOverflowing.front() == 62); CHECK(twoRegressors.roundedOverflowing.empty()); - REQUIRE(!twoRegressors.exactOverflowing.empty()); - CHECK(twoRegressors.exactOverflowing.front() == 29); + CHECK(twoRegressors.exactOverflowing.empty()); } TEST_CASE("the census draws the samples tools/census/exact_sizes.py draws", "[census]") diff --git a/test/overlay_tests.cpp b/test/overlay_tests.cpp index d06a205b..aeae6aba 100644 --- a/test/overlay_tests.cpp +++ b/test/overlay_tests.cpp @@ -950,7 +950,7 @@ TEST_CASE("a replacement by a consumer node that forwards the sink keeps the coh forwarding::quotient(var, var * var), replacementAnnex)), threeVariants); CHECK(traceOfVariant(byConsumer, roundSpecimen) - .find(" = 4000000 [replaced by jurisdiction overlay: Example Standard 12:2021 NA, NA.3.1]\n") + .find(" = 4000000 kg/(m s^2) [replaced by jurisdiction overlay: Example Standard 12:2021 NA, NA.3.1]\n") != std::string::npos); // And over two Celsius readings, whose difference is a rise in kelvins @@ -967,7 +967,7 @@ TEST_CASE("a replacement by a consumer node that forwards the sink keeps the coh auto const temperatures = formula::environment(formula::Measured { formula::Rational { 163, 10 } }, formula::Measured { formula::Rational { 277, 10 } }); CHECK(traceOfVariant(consumerRise, temperatures) - .find(" = 57/5 [replaced by jurisdiction overlay: Example Standard 12:2021 NA, NA.3.1]\n") + .find(" = 57/5 K [replaced by jurisdiction overlay: Example Standard 12:2021 NA, NA.3.1]\n") != std::string::npos); } @@ -988,7 +988,7 @@ TEST_CASE("a replacement by a consumer node with one operand of its dimension ke baseRise); auto const reading = formula::environment(formula::Measured { formula::Rational { 277, 10 } }); CHECK(traceOfVariant(replaced, reading) - .find(" = 57/5 [replaced by jurisdiction overlay: Example Standard 12:2021 NA, NA.3.1]\n") + .find(" = 57/5 K [replaced by jurisdiction overlay: Example Standard 12:2021 NA, NA.3.1]\n") != std::string::npos); } diff --git a/test/package/main.cpp b/test/package/main.cpp index 18c2bbde..e537ae80 100644 --- a/test/package/main.cpp +++ b/test/package/main.cpp @@ -28,9 +28,9 @@ int main() formula::Rational const rounded = formula::round(measured, formula::DecimalPlaces { 1 }, formula::RoundingMode::HalfAwayFromZero); - std::println("formula-cpp {} consumed successfully: {}/{}", - FORMULA_VERSION_STRING, - rounded.numerator(), - rounded.denominator()); + // 62/5, spelled by the library: a numerator or denominator is 128 bits, + // which no built-in integer holds. + formula::NumberText const roundedText = formula::fraction_text(rounded); + std::println("formula-cpp {} consumed successfully: {}", FORMULA_VERSION_STRING, roundedText.view()); return 0; } diff --git a/test/precision_tests.cpp b/test/precision_tests.cpp index 0db62e14..2e9398c3 100644 --- a/test/precision_tests.cpp +++ b/test/precision_tests.cpp @@ -245,7 +245,7 @@ TEST_CASE("abs is the absolute value, and keeps the dimension", "[precision]") // The one value Rational cannot negate, through the node: an error, never // itself back. One above it gives the largest value there is. - constexpr std::int64_t lowest = std::numeric_limits::min(); + constexpr Rational::Int lowest = std::numeric_limits::min(); STATIC_REQUIRE( formula::checked_evaluate(formula::abs(var), formula::environment(formula::Measured { Rational { lowest } })) @@ -256,7 +256,15 @@ TEST_CASE("abs is the absolute value, and keeps the dimension", "[precision]") formula::abs(var), formula::environment(formula::Measured { Rational { lowest + 1 } })) ->measurement() .value() - == Rational { std::numeric_limits::max() }); + == Rational { std::numeric_limits::max() }); + // The 64-bit minimum, which 64 bits could not negate, is an ordinary value. + constexpr std::int64_t lowest64 = std::numeric_limits::min(); + STATIC_REQUIRE( + formula::checked_evaluate( + formula::abs(var), formula::environment(formula::Measured { Rational { lowest64 } })) + ->measurement() + .value() + == Rational { std::uint64_t { 1 } << 63 }); } TEST_CASE("a series is read inside a limit expression and inside a nested level", "[precision][series]") @@ -305,20 +313,20 @@ TEST_CASE("the two passes of a precision limit are two steps, and the limit name CHECK(formula::render_trace(trace, { .maxSteps = 40 }) == "1. x_A = 40 g\n" "2. x_B = 8181/200 g\n" - "3. #1 - #2 = -181/200000\n" - "4. abs(#3) = 181/200000\n" + "3. #1 - #2 = -181/200 g\n" + "4. abs(#3) = 181/200 g\n" "5. x_A = 40 g\n" "6. x_B = 8181/200 g\n" - "7. #5 + #6 = 16181/200000\n" + "7. #5 + #6 = 16181/200 g\n" "8. 2\n" - "9. #7 / #8 = 16181/400000\n" + "9. #7 / #8 = 16181/400 g\n" "10. level (pass 1 of 2) = #9 = 16181/400 g\n" "11. 1/10 g\n" "12. 1/50\n" "13. level = 16181/400 g [bound by #16]\n" - "14. #12 * #13 = 16181/20000000\n" - "15. #11 + #14 = 18181/20000000\n" - "16. r at level #10 (pass 2 of 2) = #15 = 18181/20000000\n" + "14. #12 * #13 = 16181/20000 g\n" + "15. #11 + #14 = 18181/20000 g\n" + "16. r at level #10 (pass 2 of 2) = #15 = 18181/20000 g\n" "17. require #4 <= #16 [satisfied]\n"); // The records behind those lines, one per precision step, keyed by step. @@ -348,10 +356,10 @@ TEST_CASE("a nested precision limit's trace says which level each limit was eval "3. x_B = 8181/200 g\n" "4. level (pass 1 of 2) = #3 = 8181/200 g\n" "5. level = 8181/200 g [bound by #6]\n" - "6. r at level #4 (pass 2 of 2) = #5 = 8181/200000\n" + "6. r at level #4 (pass 2 of 2) = #5 = 8181/200 g\n" "7. level = 40 g [bound by #9]\n" - "8. #6 + #7 = 16181/200000\n" - "9. R at level #2 (pass 2 of 2) = #8 = 16181/200000\n"); + "8. #6 + #7 = 16181/200 g\n" + "9. R at level #2 (pass 2 of 2) = #8 = 16181/200 g\n"); } TEST_CASE("a limit that reads its level only through a nested limit shows it in the level's own unit", @@ -370,8 +378,8 @@ TEST_CASE("a limit that reads its level only through a nested limit shows it in "3. x_B = 8181/200 g\n" "4. level (pass 1 of 2) = #3 = 8181/200 g\n" "5. level = 8181/200 g [bound by #6]\n" - "6. r at level #4 (pass 2 of 2) = #5 = 8181/200000\n" - "7. R at level #2 (pass 2 of 2) = #6 = 8181/200000\n"); + "6. r at level #4 (pass 2 of 2) = #5 = 8181/200 g\n" + "7. R at level #2 (pass 2 of 2) = #6 = 8181/200 g\n"); } TEST_CASE("the author's rounding of the level is its own step, between the two passes", "[precision][trace-render]") @@ -388,17 +396,17 @@ TEST_CASE("the author's rounding of the level is its own step, between the two p CHECK(formula::render_trace(trace, { .maxSteps = 40 }) == "1. x_A = 40 g\n" "2. x_B = 8181/200 g\n" - "3. #1 + #2 = 16181/200000\n" + "3. #1 + #2 = 16181/200 g\n" "4. 2\n" - "5. #3 / #4 = 16181/400000\n" + "5. #3 / #4 = 16181/400 g\n" "6. round(#5, to 0 dp of g) = 40 g [nearest, ties away from zero]\n" "7. level (pass 1 of 2) = #6 = 40 g\n" "8. 1/10 g\n" "9. 1/50\n" "10. level = 40 g [bound by #13]\n" - "11. #9 * #10 = 1/1250\n" - "12. #8 + #11 = 9/10000\n" - "13. r at level #7 (pass 2 of 2) = #12 = 9/10000\n"); + "11. #9 * #10 = 4/5 g\n" + "12. #8 + #11 = 9/10 g\n" + "13. r at level #7 (pass 2 of 2) = #12 = 9/10 g\n"); } TEST_CASE("a level pass 1 cannot produce ends the limit there, and its trace says so", "[precision][trace-render]") @@ -584,10 +592,10 @@ TEST_CASE("inside a precision limit, a read says where the environment says its "2. level (pass 1 of 2) = #1 = 40 g\n" "3. 1/50\n" "4. level = 40 g [bound by #8]\n" - "5. #3 * #4 = 1/1250\n" + "5. #3 * #4 = 4/5 g\n" "6. x_B = 8181/200 g, calculated\n" - "7. #5 + #6 = 8341/200000\n" - "8. r at level #2 (pass 2 of 2) = #7 = 8341/200000\n"); + "7. #5 + #6 = 8341/200 g\n" + "8. r at level #2 (pass 2 of 2) = #7 = 8341/200 g\n"); formula::Trace<> failed {}; (void) formula::checked_evaluate( @@ -597,7 +605,7 @@ TEST_CASE("inside a precision limit, a read says where the environment says its "2. level (pass 1 of 2) = #1 = 40 g\n" "3. 1/50\n" "4. level = 40 g [bound by #8]\n" - "5. #3 * #4 = 1/1250\n" + "5. #3 * #4 = 4/5 g\n" "6. x_B = argument outside the domain of the operation, calculated\n" "7. #5 + #6 = argument outside the domain of the operation\n" "8. r at level #2 (pass 2 of 2) = #7 = argument outside the domain of the operation\n"); diff --git a/test/rational_tests.cpp b/test/rational_tests.cpp index 6acf8ba1..b9a4bbcb 100644 --- a/test/rational_tests.cpp +++ b/test/rational_tests.cpp @@ -1,10 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 +#include #include +#include #include #include +#include #include +#include +#include using formula::ArithmeticError; using formula::ArithmeticException; @@ -12,8 +17,12 @@ using formula::Rational; namespace { -constexpr Rational::Int IntMax = formula::detail::IntMax; -constexpr Rational::Int IntMin = formula::detail::IntMin; +// The bounds of `Rational::Int`, 2^127 - 1 and -2^127. +constexpr Rational::Int IntMax = std::numeric_limits::max(); +constexpr Rational::Int IntMin = std::numeric_limits::min(); +// The bounds of a 64-bit integer, which a `Rational` once stopped at. +constexpr std::int64_t Int64Max = std::numeric_limits::max(); +constexpr std::int64_t Int64Min = std::numeric_limits::min(); /// Builds a Rational in a constant expression, asserting success. consteval Rational exact(Rational::Int numerator, Rational::Int denominator) @@ -24,6 +33,13 @@ consteval Rational exact(Rational::Int numerator, Rational::Int denominator) // ---- invariants ---- +// Two 128-bit integers: 32 bytes. +static_assert(sizeof(Rational) == 32); +// Every built-in integer up to 64 bits converts, unsigned 64 bits included; +// `bool` does not. +static_assert(std::is_constructible_v); +static_assert(!std::is_constructible_v); + static_assert(Rational {}.numerator() == 0); static_assert(Rational {}.denominator() == 1); static_assert(Rational { 7 }.numerator() == 7); @@ -49,6 +65,8 @@ static_assert(!Rational::make(1, 0).has_value()); static_assert(Rational::make(1, 0).error() == ArithmeticError::DivisionByZero); static_assert(!Rational::make(IntMin, -1).has_value()); static_assert(Rational::make(IntMin, -1).error() == ArithmeticError::Overflow); +// The 64-bit minimum over -1 is 2^63, which 128 bits hold. +static_assert(Rational::make(Int64Min, -1) == Rational { std::uint64_t { 1 } << 63 }); static_assert(Rational { 0 }.is_zero()); static_assert(Rational { 3 }.is_integer()); @@ -66,6 +84,7 @@ 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(IntMax, 1).has_value()); +static_assert(Rational::from_decimal(Int64Max, 1) == Rational { Rational::Int { Int64Max } * 10 }); // ---- exact binary conversion ---- @@ -75,6 +94,12 @@ static_assert(Rational::from_double_exact(0.0) == Rational { 0 }); static_assert(Rational::from_double_exact(3.0) == Rational { 3 }); // 0.1 is not a dyadic rational, so the exact value is NOT 1/10. static_assert(Rational::from_double_exact(0.1)->denominator() != 10); +// 2^100 and 2^-100 need more than 64 bits, and fewer than 128. +static_assert(Rational::from_double_exact(0x1p100) == Rational { Rational::Int { 1 } << 100 }); +static_assert(Rational::from_double_exact(0x1p-100) == exact(1, Rational::Int { 1 } << 100)); +// 2^127 and 2^-127 do not fit. +static_assert(Rational::from_double_exact(0x1p127).error() == ArithmeticError::Overflow); +static_assert(Rational::from_double_exact(0x1p-127).error() == ArithmeticError::Overflow); // ---- ordering ---- @@ -184,7 +209,7 @@ static_assert(formula::checked_reciprocal(exact(2, 3)) == exact(3, 2)); static_assert(formula::checked_reciprocal(exact(-2, 3)) == exact(-3, 2)); static_assert(!formula::checked_reciprocal(Rational { 0 }).has_value()); // The reciprocal of n/d is d/n; when n is IntMin the result would need a -// denominator of magnitude 2^63, one past IntMax. make() grants that extra +// denominator of magnitude 2^127, one past IntMax. make() grants that extra // headroom to numerators only, since only a numerator carries the sign -- // denominators are always positive. So this is refused by make()'s own // bound, with no negation anywhere in the path. @@ -194,7 +219,9 @@ static_assert(!formula::checked_div(Rational { 1 }, Rational { 0 }).has_value()) static_assert(formula::checked_div(Rational { 1 }, Rational { 0 }).error() == ArithmeticError::DivisionByZero); static_assert(!formula::checked_add(Rational { IntMax }, Rational { 1 }).has_value()); static_assert(formula::checked_add(Rational { IntMax }, Rational { 1 }).error() == ArithmeticError::Overflow); -static_assert(!formula::checked_pow(Rational { 10 }, 19).has_value()); +// 10^19 is past 64 bits and well inside 128; 10^39 is past 2^127. +static_assert(formula::checked_pow(Rational { 10 }, 19) == Rational { Rational::Int { 1000000000000000000 } * 10 }); +static_assert(!formula::checked_pow(Rational { 10 }, 39).has_value()); // Cross-reduction must make this succeed: the naive product of the numerators // would overflow, but the canonical result is simply 1. @@ -322,15 +349,11 @@ TEST_CASE("addition is conservative at the extreme edge of the range", "[rationa // in principle perform. // // This is the safe direction to be wrong in: a reported failure, never a - // wrong number. Measured over 473984 operand pairs against 128-bit ground - // truth, it never occurs for numerators below ~10^6, which covers every - // realistic use. Removing the limitation needs 128-bit intermediates, and - // MSVC has no __int128. - // - // If a future change adds wide intermediates, this test is the one to flip. - Rational const large = *Rational::make(IntMax, 3037000500); + // wrong number. + auto const large = Rational::make(IntMax, 3037000500); + REQUIRE(large.has_value()); - auto const sum = formula::checked_add(large, large); + auto const sum = formula::checked_add(*large, *large); REQUIRE_FALSE(sum.has_value()); CHECK(sum.error() == ArithmeticError::Overflow); @@ -339,6 +362,13 @@ TEST_CASE("addition is conservative at the extreme edge of the range", "[rationa auto const representable = Rational::make(IntMax, 1518500250); REQUIRE(representable.has_value()); + // The same sum at the 64-bit bound, which 64 bits refused, is exact. + auto const large64 = Rational::make(Int64Max, 3037000500); + REQUIRE(large64.has_value()); + auto const sum64 = formula::checked_add(*large64, *large64); + REQUIRE(sum64.has_value()); + CHECK(*sum64 == Rational { Int64Max, 1518500250 }); + // Multiplication, by contrast, cross-reduces and does succeed where the // canonical result fits -- the two paths differ by design, not by accident. auto const product = formula::checked_mul(*Rational::make(IntMax, 3), *Rational::make(3, IntMax)); @@ -346,12 +376,15 @@ TEST_CASE("addition is conservative at the extreme edge of the range", "[rationa CHECK(*product == Rational { 1 }); } -TEST_CASE("integer types that cannot wrap still convert implicitly", "[rational]") +TEST_CASE("every built-in integer type converts implicitly and exactly", "[rational]") { - // The companion to negative/rational_from_wide_unsigned.cpp. That case pins - // what must NOT compile; this pins what must continue to. Before the - // constructor was constrained, a wide unsigned value converted by modular - // wraparound and SIZE_MAX became -1 silently. + // A 64-bit unsigned value is exact too: `std::uint64_t { 1 } << 63` is + // 2^63, and the largest is 2^64 - 1, never a negative number. + Rational const fromWideUnsigned = std::uint64_t { 1 } << 63; + Rational const fromLargestUnsigned = std::numeric_limits::max(); + CHECK(fromWideUnsigned == Rational { Rational::Int { 1 } << 63 }); + CHECK(fromLargestUnsigned == Rational { (Rational::Int { 1 } << 64) - 1 }); + CHECK(fromLargestUnsigned.sign() == 1); Rational const fromInt = 450; Rational const fromUnsigned = 450U; Rational const fromLong = 450L; @@ -400,15 +433,18 @@ TEST_CASE("rational: a root outside the domain is refused", "[rational]") TEST_CASE("rational: a root near the integer limit is found, not overflowed past", "[rational]") { - // 3037000000^2 = 9223369000000000000, an exact square a whisker under - // IntMax (within 0.00004% of it). The search starts with candidates whose - // square vastly exceeds what Int can hold, so it must detect that overflow - // and narrow down toward the true root -- never let an intermediate - // product silently exceed the target and send the search the wrong way, - // which would report this exact root as Inexact instead of finding it. - // Measured: dropping the early-abort guard in exact_integer_root makes - // this exact case come back Inexact, which is precisely the bug this - // test exists to catch. + // 13043817825332782212^2 is the largest exact square below IntMax. The + // search starts with candidates whose square vastly exceeds what Int can + // hold, so it must detect that overflow and narrow down toward the true + // root -- never let an intermediate product silently exceed the target and + // send the search the wrong way, which would report this exact root as + // Inexact instead of finding it. Dropping the early-abort guard in + // exact_integer_root makes such a case come back Inexact, which is + // precisely the bug this test exists to catch. + constexpr Rational::Int largestRoot { 13043817825332782212ULL }; + STATIC_REQUIRE(formula::checked_exact_nth_root(formula::Rational { largestRoot * largestRoot }, 2).value() + == formula::Rational { largestRoot }); + // 3037000000^2 = 9223369000000000000, a whisker under the 64-bit maximum. STATIC_REQUIRE(formula::checked_exact_nth_root(formula::Rational { 9223369000000000000LL }, 2).value() == formula::Rational { 3037000000LL }); @@ -420,14 +456,19 @@ TEST_CASE("rational: a root near the integer limit is found, not overflowed past TEST_CASE("rational: the root of the extreme negative is refused rather than overflowed to", "[rational]") { - // IntMin has no positive counterpart representable in Int: its magnitude is - // IntMax + 1. Negating the numerator to reach a positive intermediate is - // signed overflow, undefined behaviour, even though the true cube root - // (-2^21) is representable. This must come back Overflow, not Inexact and - // not a value. + // IntMin, -2^127, has no positive counterpart representable in Int: its + // magnitude is IntMax + 1. Negating the numerator to reach a positive + // intermediate breaks Int's contract, even though the true 127th root + // (-2) is representable. This must come back Overflow, not Inexact and + // not a value, at every degree. constexpr formula::Rational::Int extremeNegative = std::numeric_limits::min(); STATIC_REQUIRE(formula::checked_exact_nth_root(formula::Rational { extremeNegative }, 3).error() == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(formula::checked_exact_nth_root(formula::Rational { extremeNegative }, 127).error() + == formula::ArithmeticError::Overflow); + // 2^64 is a numerator now, and its 64th root is exactly 2. + STATIC_REQUIRE(formula::checked_exact_nth_root(formula::Rational { Rational::Int { 1 } << 64 }, 64).value() + == formula::Rational { 2 }); } TEST_CASE("rational: a pathologically large degree is refused quickly, not searched for", "[rational]") @@ -471,3 +512,58 @@ TEST_CASE("rational: Pi's documented error bound is pinned, exactly", "[rational STATIC_REQUIRE(formula::Pi > belowPiBy8e17); STATIC_REQUIRE(formula::Pi < abovePiBy8e17); } + +TEST_CASE("a Rational::Int is built from a magnitude, or refused when it does not fit", "[rational]") +{ + using formula::detail::UInt128; + using formula::detail::rational_int_from_magnitude; + constexpr auto largestMagnitude = formula::detail::wide_magnitude(std::numeric_limits::max()); + STATIC_REQUIRE(rational_int_from_magnitude(UInt128::from_u64(5), true) == std::optional { -5 }); + STATIC_REQUIRE(rational_int_from_magnitude(largestMagnitude, false) + == std::optional { std::numeric_limits::max() }); + STATIC_REQUIRE(rational_int_from_magnitude(formula::detail::u128_add(largestMagnitude, UInt128::from_u64(1)), true) + == std::optional { std::numeric_limits::min() }); + STATIC_REQUIRE(rational_int_from_magnitude(formula::detail::u128_add(largestMagnitude, UInt128::from_u64(1)), false) + == std::nullopt); +} + +TEST_CASE("a Rational holds 128-bit numerators and denominators", "[rational]") +{ + using formula::Int128; + using formula::Rational; + constexpr Int128 largest = std::numeric_limits::max(); + constexpr Int128 smallest = std::numeric_limits::min(); + STATIC_REQUIRE(std::is_same_v); + // The minimum is a numerator; its negation, absolute value and + // reciprocal are not representable, and are refused. + constexpr auto lowest = Rational::make(smallest, 1); + STATIC_REQUIRE(lowest.has_value()); + STATIC_REQUIRE(lowest->numerator() == smallest); + STATIC_REQUIRE(formula::checked_negate(*lowest).error() == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(formula::checked_abs(*lowest).error() == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(formula::checked_reciprocal(*lowest).error() == formula::ArithmeticError::Overflow); + // One past the largest is refused where the 64-bit sum used to be. + STATIC_REQUIRE(formula::checked_add(Rational { largest }, Rational { 1 }).error() == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(formula::checked_add(Rational { std::numeric_limits::max() }, Rational { 1 }).has_value()); +} + +TEST_CASE("the longest numbers a Rational holds are spelled in full", "[rational][number-text]") +{ + using formula::Int128; + using formula::Rational; + constexpr Int128 largest = std::numeric_limits::max(); + auto const lowest = Rational::make(std::numeric_limits::min(), 1); + auto const nearOne = Rational::make(largest, largest - 1); + auto const third = Rational::make(largest, 3); + REQUIRE(lowest.has_value()); + REQUIRE(nearOne.has_value()); + REQUIRE(third.has_value()); + auto const lowestText = formula::fraction_text(*lowest); + CHECK(lowestText.view() == "-170141183460469231731687303715884105728"); + auto const nearOneText = formula::fraction_text(*nearOne); + CHECK(nearOneText.view() == "170141183460469231731687303715884105727/170141183460469231731687303715884105726"); + auto const thirdOfLargest = formula::checked_decimal_text( + *third, formula::DecimalPlaces { 18 }, formula::RoundingMode::HalfEven, formula::DecimalPadding::Trimmed); + REQUIRE(thirdOfLargest.has_value()); + CHECK(thirdOfLargest->view() == "56713727820156410577229101238628035242.333333333333333333"); +} diff --git a/test/record_render_tests.cpp b/test/record_render_tests.cpp index b1c372e8..4f68b5bb 100644 --- a/test/record_render_tests.cpp +++ b/test/record_render_tests.cpp @@ -255,8 +255,8 @@ TEST_CASE("a scope's trace line shows the unit its operand's line does", "[recor INFO(computedText); CHECK(computedText == "1. F = 55600 N, from record Reference (sample 23, test 3)\n" "2. x_m = 139 mm, from record Reference (sample 23, test 3)\n" - "3. #1 / #2 = 400000\n" - "4. #3 from record Reference (sample 23, test 3) = 400000\n"); + "3. #1 / #2 = 400000 kg/s^2\n" + "4. #3 from record Reference (sample 23, test 3) = 400000 kg/s^2\n"); } TEST_CASE("an overlay's constant used here and inside a scope has one row, of no record", "[record-render]") diff --git a/test/record_trace_tests.cpp b/test/record_trace_tests.cpp index 50972550..5024035e 100644 --- a/test/record_trace_tests.cpp +++ b/test/record_trace_tests.cpp @@ -333,20 +333,26 @@ 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]") { - // 1296 bytes, measured on cl, clang-cl, g++ 13 and 14, and clang++ with - // libstdc++, all 64-bit. It was 1008 until a `Dimension` could carry named base - // dimensions: each of its four slots holds a 16-byte name and an 8-byte - // exponent, so a `Dimension` grew from 56 bytes to 152 and a `Unit` from - // 152 to 248, and a step holds one dimension and two units -- 3 x 96 = 288 - // bytes more. Records' per-step facts -- the source, whether a replaced - // entry was empty, and the record's number -- still fit in padding `Step` - // already had; each origin and each lineage comparison lives once, in the - // trace's side tables. A checked standard library's containers are larger, - // and so is every step there, so only an unchecked 64-bit build pins the - // number. + // 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 + // 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 + // number -- still fit in padding `Step` already had; each origin and each + // lineage comparison lives once, in the trace's side tables. A checked + // standard library's containers are larger, and so is every step there, so + // only an unchecked 64-bit build pins the number. The optional value's 40 + // bytes depend on no container, so they are pinned on every build. + static_assert(sizeof(std::optional) == 40); #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) == 1296)); + STATIC_REQUIRE((sizeof(void*) != 8 || sizeof(formula::Step) == 1320)); #endif } diff --git a/test/rejection_tests.cpp b/test/rejection_tests.cpp index 1107172f..0872708a 100644 --- a/test/rejection_tests.cpp +++ b/test/rejection_tests.cpp @@ -380,7 +380,7 @@ TEST_CASE("a deviation from the mean of Celsius readings reads in the coherent u + "2. 3 K\n" "3. pass 1: 5 values, mean 1253/50 " + degreesCelsius + "\n" + "4. rejected element 3 of 5 (293/10 " + degreesCelsius - + ") in pass 1: abs(x - mean) = 106/25 > 3 (deviation from mean)\n" + + ") in pass 1: abs(x - mean) = 106/25 K > 3 K (deviation from mean)\n" "5. 3 K\n" "6. pass 2: 4 values, mean 24 " + degreesCelsius + "\n" + "7. settled: 1 rejected, 4 remain\n"); @@ -395,7 +395,7 @@ TEST_CASE("a deviation from the mean of Celsius readings reads in the coherent u formula::RecordingSink<> { squared }); CHECK(formula::render_trace(squared, { .maxSteps = 40 }).find( "4. rejected element 3 of 5 (293/10 " + degreesCelsius - + ") in pass 1: (x - mean)^2 = 11236/625 > limit^2 * s^2 = 69433/4000 (deviation in standard deviations)\n") + + ") in pass 1: (x - mean)^2 = 11236/625 K^2 > limit^2 * s^2 = 69433/4000 K^2 (deviation in standard deviations)\n") != std::string::npos); } @@ -418,12 +418,12 @@ TEST_CASE("a rejection over a unit with no symbol reads its means and deviations formula::Trace<> trace {}; (void) formula::checked_evaluate_rejection(twoGrams, unnamed, formula::RecordingSink<> { trace }); CHECK(formula::render_trace(trace, { .maxSteps = 40 }) - == "1. m_u = 201/5; 199/5; 81/2; 44\n" + == "1. m_u = 201/5000 kg; 199/5000 kg; 81/2000 kg; 11/250 kg\n" "2. 2 g\n" - "3. pass 1: 4 values, mean 329/8000\n" - "4. rejected element 4 of 4 (11/250) in pass 1: abs(x - mean) = 23/8000 > 1/500 (deviation from mean)\n" + "3. pass 1: 4 values, mean 329/8000 kg\n" + "4. rejected element 4 of 4 (11/250 kg) in pass 1: abs(x - mean) = 23/8000 kg > 1/500 kg (deviation from mean)\n" "5. 2 g\n" - "6. pass 2: 3 values, mean 241/6000\n" + "6. pass 2: 3 values, mean 241/6000 kg\n" "7. settled: 1 rejected, 3 remain\n"); } @@ -480,17 +480,17 @@ TEST_CASE("every rejection is a step naming the value, the statistic, the limit == "1. m = 201/5 g; 199/5 g; 81/2 g; 44 g; 40 g; 433/10 g\n" "2. 3/50\n" "3. pass mean = 413/10 g\n" - "4. #2 * #3 = 1239/500000\n" + "4. #2 * #3 = 1239/500 g\n" "5. pass 1: 6 values, mean 413/10 g\n" "6. rejected element 4 of 6 (44 g) in pass 1: abs(x - mean) = 27/10 g > 1239/500 g (deviation from mean)\n" "7. 3/50\n" "8. pass mean = 1019/25 g\n" - "9. #7 * #8 = 3057/1250000\n" + "9. #7 * #8 = 3057/1250 g\n" "10. pass 2: 5 values, mean 1019/25 g\n" "11. rejected element 6 of 6 (433/10 g) in pass 2: abs(x - mean) = 127/50 g > 3057/1250 g (deviation from mean)\n" "12. 3/50\n" "13. pass mean = 321/8 g\n" - "14. #12 * #13 = 963/400000\n" + "14. #12 * #13 = 963/400 g\n" "15. pass 3: 4 values, mean 321/8 g\n" "16. settled: 2 rejected, 4 remain\n"); @@ -500,12 +500,12 @@ TEST_CASE("every rejection is a step naming the value, the statistic, the limit == "1. m = 201/5 g; 199/5 g; 81/2 g; 44 g; 40 g; 433/10 g\n" "2. 3/50\n" "3. pass mean = 413/10 g\n" - "4. #2 * #3 = 1239/500000\n" + "4. #2 * #3 = 1239/500 g\n" "5. pass 1: 6 values, mean 413/10 g\n" "6. rejected element 4 of 6 (44 g) in pass 1: abs(x - mean) = 27/10 g > 1239/500 g (deviation from mean)\n" "7. 3/50\n" "8. pass mean = 1019/25 g\n" - "9. #7 * #8 = 3057/1250000\n" + "9. #7 * #8 = 3057/1250 g\n" "10. pass 2: 5 values, mean 1019/25 g\n" "11. element 6 of 6 would be rejection 2 of at most 1: discard the determinations and repeat the test [Example " "Standard, 7.4]\n"); @@ -523,7 +523,7 @@ TEST_CASE("every rejection is a step naming the value, the statistic, the limit == "1. m = 201/5 g; 199/5 g; 81/2 g; 226/5 g; 40 g; 186/5 g\n" "2. 3/50\n" "3. pass mean = 2429/60 g\n" - "4. #2 * #3 = 2429/1000000\n" + "4. #2 * #3 = 2429/1000 g\n" "5. pass 1: 6 values, mean 2429/60 g\n" "6. elements 4 and 6 of 6 would be rejections 1 and 2 of at most 1: discard the determinations and repeat the " "test [Example Standard, 7.4]\n"); @@ -554,12 +554,12 @@ TEST_CASE("every rejection is a step naming the value, the statistic, the limit == "1. m = 201/5 g; 199/5 g; 81/2 g; 44 g; 40 g; 433/10 g\n" "2. 3/50\n" "3. pass mean = 413/10 g\n" - "4. #2 * #3 = 1239/500000\n" + "4. #2 * #3 = 1239/500 g\n" "5. pass 1: 6 values, mean 413/10 g\n" "6. rejected element 4 of 6 (44 g) in pass 1: abs(x - mean) = 27/10 g > 1239/500 g (deviation from mean)\n" "7. 3/50\n" "8. pass mean = 1019/25 g\n" - "9. #7 * #8 = 3057/1250000\n" + "9. #7 * #8 = 3057/1250 g\n" "10. pass 2: 5 values, mean 1019/25 g\n" "11. element 6 of 6 would leave 4 of at least 5: discard the determinations and repeat the test [Example " "Standard, 7.4]\n"); @@ -688,17 +688,17 @@ TEST_CASE("a rejection joins a method: evaluated, overlaid, rendered, documented == "1. x_m = 201/5 g; 199/5 g; 81/2 g; 226/5 g; 40 g; 186/5 g\n" "2. t = 3/100 [fixed by jurisdiction overlay: Example Standard 1:2020 NA, NA.2]\n" "3. pass mean = 2429/60 g\n" - "4. #2 * #3 = 2429/2000000\n" + "4. #2 * #3 = 2429/2000 g\n" "5. pass 1: 6 values, mean 2429/60 g\n" "6. rejected element 4 of 6 (226/5 g) in pass 1: abs(x - mean) = 283/60 g > 2429/2000 g (deviation from mean)\n" "7. t = 3/100 [fixed by jurisdiction overlay: Example Standard 1:2020 NA, NA.2]\n" "8. pass mean = 1977/50 g\n" - "9. #7 * #8 = 5931/5000000\n" + "9. #7 * #8 = 5931/5000 g\n" "10. pass 2: 5 values, mean 1977/50 g\n" "11. rejected element 6 of 6 (186/5 g) in pass 2: abs(x - mean) = 117/50 g > 5931/5000 g (deviation from mean)\n" "12. t = 3/100 [fixed by jurisdiction overlay: Example Standard 1:2020 NA, NA.2]\n" "13. pass mean = 321/8 g\n" - "14. #12 * #13 = 963/800000\n" + "14. #12 * #13 = 963/800 g\n" "15. pass 3: 4 values, mean 321/8 g\n" "16. settled: 2 rejected, 4 remain\n" "17. sample_mean(#16) = 321/8 g\n" @@ -754,9 +754,10 @@ TEST_CASE("a negative limit is no rule, under either criterion", "[rejection]") TEST_CASE("a pass that fails says what failed, and the rejection claims its steps", "[rejection][trace-render]") { - // The mean: INT64_MAX kg and 1 kg leave 64 bits at element 2. + // The mean: the largest Rational::Int kg and 1 kg leave 128 bits at + // element 2. constexpr auto heavyMean = formula::environment( - formula::measured_series(formula::Measured { Rational { std::numeric_limits::max() } }, + formula::measured_series(formula::Measured { Rational { std::numeric_limits::max() } }, formula::Measured { rat(1) }, formula::Measured { rat(2) })); constexpr auto heavyRejection = @@ -769,21 +770,21 @@ TEST_CASE("a pass that fails says what failed, and the rejection claims its step (void) formula::checked_evaluate( formula::sample_mean(heavyRejection), heavyMean, formula::RecordingSink<> { meanTrace }); CHECK(formula::render_trace(meanTrace, { .maxSteps = 20 }) - == "1. m_h = 9223372036854775807 kg; 1 kg; 2 kg\n" + == "1. m_h = 170141183460469231731687303715884105727 kg; 1 kg; 2 kg\n" "2. pass 1: 3 values, mean overflow in exact arithmetic\n" "3. failed in pass 1: the mean: overflow in exact arithmetic at element 2 of 3\n" "4. sample_mean(#3) = overflow in exact arithmetic\n"); - // The variance: the census's six-decimal sample (40.053270 ... 40.131659 g) - // under 7/4 standard deviations. The mean fits; the squared deviations' - // total does not, at element 1 -- not the mean, which the pass line - // shows. - constexpr auto fine = sampleOf(rat(40053270, 1000000), - rat(39475922, 1000000), - rat(39025798, 1000000), - rat(40615904, 1000000), - rat(39418416, 1000000), - rat(40131659, 1000000)); + // The variance: six masses read to 16 decimal places of a gram, under + // 7/4 standard deviations. The mean fits; the squared deviations' total + // does not, at element 1 -- not the mean, which the pass line shows. + constexpr std::int64_t sixteenPlaces = 10'000'000'000'000'000; + constexpr auto fine = sampleOf(rat(400532701234567891, sixteenPlaces), + rat(394759221234567893, sixteenPlaces), + rat(390257981234567897, sixteenPlaces), + rat(406159041234567899, sixteenPlaces), + rat(394184161234567901, sixteenPlaces), + rat(401316591234567919, sixteenPlaces)); constexpr auto fineRejection = rejectionOf(sevenQuarters); constexpr auto varianceFailed = formula::checked_evaluate_rejection(fineRejection, fine); STATIC_REQUIRE(varianceFailed.error().error == formula::ArithmeticError::Overflow); @@ -792,11 +793,26 @@ TEST_CASE("a pass that fails says what failed, and the rejection claims its step (void) formula::checked_evaluate( formula::sample_mean(fineRejection), fine, formula::RecordingSink<> { varianceTrace }); CHECK(formula::render_trace(varianceTrace, { .maxSteps = 20 }) - == "1. m = 4005327/100000 g; 19737961/500000 g; 19512899/500000 g; 1269247/31250 g; 2463651/62500 g; " - "40131659/1000000 g\n" - "2. pass 1: 6 values, mean 238720969/6000000 g\n" + == "1. m = 400532701234567891/10000000000000000 g; 394759221234567893/10000000000000000 g; " + "390257981234567897/10000000000000000 g; 406159041234567899/10000000000000000 g; " + "394184161234567901/10000000000000000 g; 401316591234567919/10000000000000000 g\n" + "2. pass 1: 6 values, mean 11936048487037037/300000000000000 g\n" "3. failed in pass 1: the variance: overflow in exact arithmetic at element 1 of 6\n" "4. sample_mean(#3) = overflow in exact arithmetic\n"); + // The census's six-decimal sample (40.053270 ... 40.131659 g), which + // overflowed 64 bits, keeps all six: no deviation reaches 7/4 of the + // standard deviation, and the mean is 238720969/6000000 g. + constexpr auto micrograms = sampleOf(rat(40053270, 1000000), + rat(39475922, 1000000), + rat(39025798, 1000000), + rat(40615904, 1000000), + rat(39418416, 1000000), + rat(40131659, 1000000)); + constexpr auto microgramsKept = formula::checked_evaluate_rejection(fineRejection, micrograms); + STATIC_REQUIRE(microgramsKept.has_value()); + STATIC_REQUIRE(microgramsKept->rejected().empty()); + STATIC_REQUIRE(formula::checked_evaluate(formula::sample_mean(fineRejection), micrograms)->measurement().value() + == rat(238720969, 6000000)); // The limit: 1 kg / (pass n - 3) is a division by zero in a pass of 3. constexpr auto smallHeavy = formula::environment(formula::measured_series( @@ -823,10 +839,13 @@ TEST_CASE("a pass that fails says what failed, and the rejection claims its step "8. failed in pass 1: the limit: division by zero\n" "9. sample_mean(#8) = division by zero\n"); - // limit^2 * s^2: 4 * 10^9 standard deviations squared leaves 64 bits. + // limit^2 * s^2: 5 * 10^18 standard deviations squared, 2.5 * 10^37, + // times the variance of 7 kg^2 leaves 128 bits. constexpr auto thresholdRejection = formula::without_outliers, formula::KeepAtLeast<3>>( - formula::series, formula::deviation_in_stddevs(formula::number(rat(4'000'000'000))), repeatTest); + formula::series, + formula::deviation_in_stddevs(formula::number(rat(5'000'000'000'000'000'000))), + repeatTest); constexpr auto thresholdFailed = formula::checked_evaluate_rejection(thresholdRejection, smallHeavy); STATIC_REQUIRE(thresholdFailed.error().error == formula::ArithmeticError::Overflow); STATIC_REQUIRE(!thresholdFailed.error().element.has_value()); @@ -835,16 +854,25 @@ TEST_CASE("a pass that fails says what failed, and the rejection claims its step thresholdRejection, smallHeavy, formula::RecordingSink<> { thresholdTrace }); CHECK(formula::render_trace(thresholdTrace, { .maxSteps = 20 }) == "1. m_h = 0 kg; 1 kg; 5 kg\n" - "2. 4000000000\n" + "2. 5000000000000000000\n" "3. pass 1: 3 values, mean 2 kg\n" "4. failed in pass 1: limit^2 * s^2: overflow in exact arithmetic\n"); - - // A deviation: 4e18, 4e18 and -4e18 kg have a mean that fits, and - // 4e18 - 4e18/3 does not, at element 1. + // 4 * 10^9 standard deviations, which overflowed 64 bits, rejects none. + constexpr auto wideThreshold = + formula::without_outliers, formula::KeepAtLeast<3>>( + formula::series, formula::deviation_in_stddevs(formula::number(rat(4'000'000'000))), repeatTest); + constexpr auto noneRejected = formula::checked_evaluate_rejection(wideThreshold, smallHeavy); + STATIC_REQUIRE(noneRejected.has_value()); + STATIC_REQUIRE(noneRejected->rejected().empty()); + + // A deviation: 7e37, 7e37 and -7e37 kg have a mean that fits, and + // 7e37 - 7e37/3 does not, at element 1: it is formed over 3, as + // 3 * 7e37 - 7e37. + constexpr Rational::Int sevenE37 = Rational::Int { 7'000'000'000'000'000'000 } * 10'000'000'000'000'000'000ULL; constexpr auto wide = - formula::environment(formula::measured_series(formula::Measured { rat(4'000'000'000'000'000'000) }, - formula::Measured { rat(4'000'000'000'000'000'000) }, - formula::Measured { rat(-4'000'000'000'000'000'000) })); + formula::environment(formula::measured_series(formula::Measured { Rational { sevenE37 } }, + formula::Measured { Rational { sevenE37 } }, + formula::Measured { Rational { -sevenE37 } })); constexpr auto wideRejection = formula::without_outliers, formula::KeepAtLeast<2>>( formula::series, formula::deviation_from_mean(formula::constant(rat(3'000'000'000'000'000'000))), @@ -856,11 +884,20 @@ TEST_CASE("a pass that fails says what failed, and the rejection claims its step (void) formula::checked_evaluate( formula::sample_mean(wideRejection), wide, formula::RecordingSink<> { statisticTrace }); CHECK(formula::render_trace(statisticTrace, { .maxSteps = 20 }) - == "1. m_h = 4000000000000000000 kg; 4000000000000000000 kg; -4000000000000000000 kg\n" + == "1. m_h = 70000000000000000000000000000000000000 kg; 70000000000000000000000000000000000000 kg; " + "-70000000000000000000000000000000000000 kg\n" "2. 3000000000000000000 kg\n" - "3. pass 1: 3 values, mean 4000000000000000000/3 kg\n" + "3. pass 1: 3 values, mean 70000000000000000000000000000000000000/3 kg\n" "4. failed in pass 1: the deviation: overflow in exact arithmetic at element 1 of 3\n" "5. sample_mean(#4) = overflow in exact arithmetic\n"); + // 4e18, 4e18 and -4e18 kg, which overflowed 64 bits: -4e18 lies 16e18/3 + // from the mean, past 3e18, and is rejected; the two left agree. + constexpr auto wide64 = + formula::environment(formula::measured_series(formula::Measured { rat(4'000'000'000'000'000'000) }, + formula::Measured { rat(4'000'000'000'000'000'000) }, + formula::Measured { rat(-4'000'000'000'000'000'000) })); + STATIC_REQUIRE(formula::checked_evaluate(formula::sample_mean(wideRejection), wide64)->measurement().value() + == rat(4'000'000'000'000'000'000)); } TEST_CASE("an absent limit decides nothing, and the outcome is empty", "[rejection]") @@ -908,7 +945,7 @@ TEST_CASE("an aborted rejection keeps its survivors, and names both bounds when == "1. m = 201/5 g; 199/5 g; 81/2 g; 226/5 g; 40 g; 186/5 g\n" "2. 3/50\n" "3. pass mean = 2429/60 g\n" - "4. #2 * #3 = 2429/1000000\n" + "4. #2 * #3 = 2429/1000 g\n" "5. pass 1: 6 values, mean 2429/60 g\n" "6. elements 4 and 6 of 6 would be rejections 1 and 2 of at most 1 and would leave 4 of at least 5: discard " "the determinations and repeat the test [Example Standard, 7.4]\n"); @@ -1002,7 +1039,7 @@ TEST_CASE("a rejection record that contradicts itself is refused, not printed", // element for the range, which no element owns, or in pass 0. formula::Trace<> failed {}; constexpr auto heavyMean = formula::environment( - formula::measured_series(formula::Measured { Rational { std::numeric_limits::max() } }, + formula::measured_series(formula::Measured { Rational { std::numeric_limits::max() } }, formula::Measured { rat(1) }, formula::Measured { rat(2) })); (void) formula::checked_evaluate_rejection( @@ -1028,12 +1065,13 @@ TEST_CASE("a rejection record that contradicts itself is refused, not printed", TEST_CASE("a gap_to_range overflow is the range's, at no element; too few for standard deviations says so", "[rejection][trace-render]") { - // 5e18, 0 and -5e18 kg: the mean fits (0), the range, 1e19, does not. + // 1e38, 0 and -1e38 kg: the mean fits (0), the range, 2e38, does not. // It is the range's failure, and the range belongs to no element. + constexpr Rational::Int oneE38 = Rational::Int { 10'000'000'000'000'000'000ULL } * 10'000'000'000'000'000'000ULL; constexpr auto wide = formula::environment( - formula::measured_series(formula::Measured { Rational { 5'000'000'000'000'000'000 } }, + formula::measured_series(formula::Measured { Rational { oneE38 } }, formula::Measured { rat(0) }, - formula::Measured { Rational { -5'000'000'000'000'000'000 } })); + formula::Measured { Rational { -oneE38 } })); constexpr auto gapped = formula::without_outliers, formula::KeepAtLeast<2>>( formula::series, formula::gap_to_range(formula::number(rat(1, 10))), repeatTest); constexpr auto overflowed = formula::checked_evaluate_rejection(gapped, wide); @@ -1043,6 +1081,18 @@ TEST_CASE("a gap_to_range overflow is the range's, at no element; too few for st (void) formula::checked_evaluate_rejection(gapped, wide, formula::RecordingSink<> { rangeTrace }); CHECK(formula::render_trace(rangeTrace, { .maxSteps = 20 }) .ends_with("failed in pass 1: the range: overflow in exact arithmetic\n")); + // 5e18, 0 and -5e18 kg, whose range of 1e19 overflowed 64 bits: both + // extremes lie half the range from their neighbour, past a tenth, and + // at most one may go -- the verdict, rejecting none. + constexpr auto wide64 = formula::environment( + formula::measured_series(formula::Measured { Rational { 5'000'000'000'000'000'000 } }, + formula::Measured { rat(0) }, + formula::Measured { Rational { -5'000'000'000'000'000'000 } })); + auto const gapped64 = formula::checked_evaluate_rejection(gapped, wide64); + REQUIRE(gapped64.has_value()); + CHECK(gapped64->rejected().empty()); + CHECK(gapped64->outcome().is_verdict()); + CHECK(!formula::number_of(*gapped64).has_value()); // Fixture B at 1/10 standard deviations, keeping at least three -- the // fewest a deviation in standard deviations may keep: every pass @@ -1325,10 +1375,11 @@ TEST_CASE("a rejection of observations reads a critical value at the count made, TEST_CASE("a rejection of observations names an observation where it fails, or would reject", "[rejection][trace-render]") { - // 1 kg and 2^62 - 1 kg total 2^62 kg; the third, 2^62 + 9 kg, takes the - // total past 2^63 - 1: the mean fails at observation 3. - constexpr auto heavyObserved = formula::environment(formula::MeasuredObservations( - rat(1), Rational { 4611686018427387903 }, Rational { 4611686018427387913 })); + // 1 kg and 2^126 - 1 kg total 2^126 kg; the third, 2^126 + 9 kg, takes + // the total past 2^127 - 1: the mean fails at observation 3. + constexpr Rational::Int half = Rational::Int { 1 } << 126; + constexpr auto heavyObserved = formula::environment( + formula::MeasuredObservations(rat(1), Rational { half - 1 }, Rational { half + 9 })); constexpr auto heavyRejection = formula::without_outliers, formula::KeepAtLeast<2>>( formula::observations, @@ -1338,10 +1389,19 @@ TEST_CASE("a rejection of observations names an observation where it fails, or w (void) formula::checked_evaluate( formula::sample_mean(heavyRejection), heavyObserved, formula::RecordingSink<> { trace }); CHECK(formula::render_trace(trace, { .maxSteps = 20 }) - == "1. m_h = 1 kg; 4611686018427387903 kg; 4611686018427387913 kg\n" + == "1. m_h = 1 kg; 85070591730234615865843651857942052863 kg; 85070591730234615865843651857942052873 kg\n" "2. pass 1: 3 values, mean overflow in exact arithmetic\n" "3. failed in pass 1: the mean: overflow in exact arithmetic at observation 3 of 3\n" "4. sample_mean(#3) = overflow in exact arithmetic\n"); + // With 2^62 - 1 kg and 2^62 + 9 kg, which overflowed 64 bits, the mean + // is found, and the rejection answers. + auto const heavy64 = formula::checked_evaluate_rejection( + heavyRejection, + formula::environment(formula::MeasuredObservations( + rat(1), Rational { 4611686018427387903 }, Rational { 4611686018427387913 }))); + REQUIRE(heavy64.has_value()); + CHECK(heavy64->rejected().size() == 1); + CHECK(heavy64->outcome().is_verdict()); // An abort over observations names the observation it would reject -- // and, every exceeding one at once, the observations. @@ -1365,46 +1425,53 @@ TEST_CASE("a rejection of observations names an observation where it fails, or w TEST_CASE("a statistic of a rejection that fails names the determination the rejection was given", "[rejection][trace-render]") { - // The census's sample A at 6 dp, 40.053270 ... 40.131659 g. Nothing lies - // 2 g from the mean, so all six survive; their squared deviations then - // overflow. The rejection's step lists no elements: the position counts - // the sample the rejection was given -- elements of a series, and - // observations of observations. + // Six masses read to 16 decimal places of a gram. Nothing lies 2 g from + // the mean, so all six survive; their squared deviations then overflow. + // The rejection's step lists no elements: the position counts the sample + // the rejection was given -- elements of a series, and observations of + // observations. + constexpr std::int64_t sixteenPlaces = 10'000'000'000'000'000; constexpr auto withinTwoGrams = formula::deviation_from_mean(formula::constant(rat(2))); constexpr auto kept = formula::without_outliers, formula::KeepAtLeast<4>>( formula::series, withinTwoGrams, repeatTest); formula::Trace<> overSeries {}; (void) formula::checked_evaluate(formula::sample_variance(kept), - sampleOf(rat(40053270, 1000000), - rat(39475922, 1000000), - rat(39025798, 1000000), - rat(40615904, 1000000), - rat(39418416, 1000000), - rat(40131659, 1000000)), + sampleOf(rat(400532701234567891, sixteenPlaces), + rat(394759221234567893, sixteenPlaces), + rat(390257981234567897, sixteenPlaces), + rat(406159041234567899, sixteenPlaces), + rat(394184161234567901, sixteenPlaces), + rat(401316591234567919, sixteenPlaces)), formula::RecordingSink<> { overSeries }); - CHECK(formula::render_trace(overSeries, { .maxSteps = 20 }) == "1. m = 4005327/100000 g; 19737961/500000 g; 19512899/500000 g; 1269247/31250 g; 2463651/62500 g; 40131659/1000000 g\n" - "2. 2 g\n" - "3. pass 1: 6 values, mean 238720969/6000000 g\n" - "4. settled: 0 rejected, 6 remain\n" - "5. sample_variance(#4) = overflow in exact arithmetic at element 1\n"); + CHECK(formula::render_trace(overSeries, { .maxSteps = 20 }) + == "1. m = 400532701234567891/10000000000000000 g; 394759221234567893/10000000000000000 g; " + "390257981234567897/10000000000000000 g; 406159041234567899/10000000000000000 g; " + "394184161234567901/10000000000000000 g; 401316591234567919/10000000000000000 g\n" + "2. 2 g\n" + "3. pass 1: 6 values, mean 11936048487037037/300000000000000 g\n" + "4. settled: 0 rejected, 6 remain\n" + "5. sample_variance(#4) = overflow in exact arithmetic at element 1\n"); constexpr auto keptObserved = formula::without_outliers, formula::KeepAtLeast<4>>( observedMasses, withinTwoGrams, repeatTest); formula::Trace<> overObservations {}; (void) formula::checked_evaluate(formula::sample_variance(keptObserved), - observedOf(rat(40053270, 1000000), - rat(39475922, 1000000), - rat(39025798, 1000000), - rat(40615904, 1000000), - rat(39418416, 1000000), - rat(40131659, 1000000)), + observedOf(rat(400532701234567891, sixteenPlaces), + rat(394759221234567893, sixteenPlaces), + rat(390257981234567897, sixteenPlaces), + rat(406159041234567899, sixteenPlaces), + rat(394184161234567901, sixteenPlaces), + rat(401316591234567919, sixteenPlaces)), formula::RecordingSink<> { overObservations }); - CHECK(formula::render_trace(overObservations, { .maxSteps = 20 }) == "1. m = 4005327/100000 g; 19737961/500000 g; 19512899/500000 g; 1269247/31250 g; 2463651/62500 g; 40131659/1000000 g\n" - "2. 2 g\n" - "3. pass 1: 6 values, mean 238720969/6000000 g\n" - "4. settled: 0 rejected, 6 remain\n" - "5. sample_variance(#4) = overflow in exact arithmetic at observation 1\n"); + CHECK(formula::render_trace(overObservations, { .maxSteps = 20 }) + == "1. m = 400532701234567891/10000000000000000 g; 394759221234567893/10000000000000000 g; " + "390257981234567897/10000000000000000 g; 406159041234567899/10000000000000000 g; " + "394184161234567901/10000000000000000 g; 401316591234567919/10000000000000000 g\n" + "2. 2 g\n" + "3. pass 1: 6 values, mean 11936048487037037/300000000000000 g\n" + "4. settled: 0 rejected, 6 remain\n" + "5. sample_variance(#4) = overflow in exact arithmetic at observation 1\n"); // With 30 g entered first, 30 g is rejected, and the overflow at the // first survivor is at the second determination as entered -- not the @@ -1414,21 +1481,38 @@ TEST_CASE("a statistic of a rejection that fails names the determination the rej formula::Trace<> afterRejected {}; (void) formula::checked_evaluate(formula::sample_variance(afterOne), sampleOf(rat(30), - rat(40053270, 1000000), - rat(39475922, 1000000), - rat(39025798, 1000000), - rat(40615904, 1000000), - rat(39418416, 1000000), - rat(40131659, 1000000)), + rat(400532701234567891, sixteenPlaces), + rat(394759221234567893, sixteenPlaces), + rat(390257981234567897, sixteenPlaces), + rat(406159041234567899, sixteenPlaces), + rat(394184161234567901, sixteenPlaces), + rat(401316591234567919, sixteenPlaces)), formula::RecordingSink<> { afterRejected }); - CHECK(formula::render_trace(afterRejected, { .maxSteps = 20 }) == "1. m = 30 g; 4005327/100000 g; 19737961/500000 g; 19512899/500000 g; 1269247/31250 g; 2463651/62500 g; 40131659/1000000 g\n" - "2. 2 g\n" - "3. pass 1: 7 values, mean 268720969/7000000 g\n" - "4. rejected element 1 of 7 (30 g) in pass 1: abs(x - mean) = 58720969/7000000 g > 2 g (deviation from mean)\n" - "5. 2 g\n" - "6. pass 2: 6 values, mean 238720969/6000000 g\n" - "7. settled: 1 rejected, 6 remain\n" - "8. sample_variance(#7) = overflow in exact arithmetic at element 2\n"); + CHECK(formula::render_trace(afterRejected, { .maxSteps = 20 }) + == "1. m = 30 g; 400532701234567891/10000000000000000 g; 394759221234567893/10000000000000000 g; " + "390257981234567897/10000000000000000 g; 406159041234567899/10000000000000000 g; " + "394184161234567901/10000000000000000 g; 401316591234567919/10000000000000000 g\n" + "2. 2 g\n" + "3. pass 1: 7 values, mean 13436048487037037/350000000000000 g\n" + "4. rejected element 1 of 7 (30 g) in pass 1: abs(x - mean) = 2936048487037037/350000000000000 g > 2 g " + "(deviation from mean)\n" + "5. 2 g\n" + "6. pass 2: 6 values, mean 11936048487037037/300000000000000 g\n" + "7. settled: 1 rejected, 6 remain\n" + "8. sample_variance(#7) = overflow in exact arithmetic at element 2\n"); + + // The census's sample A at 6 dp, 40.053270 ... 40.131659 g, which + // overflowed 64 bits: all six survive, and their variance is exact. + auto const micrograms = formula::checked_evaluate(formula::sample_variance(kept), + sampleOf(rat(40053270, 1000000), + rat(39475922, 1000000), + rat(39025798, 1000000), + rat(40615904, 1000000), + rat(39418416, 1000000), + rat(40131659, 1000000))); + REQUIRE(micrograms.has_value()); + REQUIRE(micrograms->is_value()); + CHECK(micrograms->measurement().value() == Rational { 2026588050217, 6000000000000 }); } TEST_CASE("observations fewer than KeepAtLeast give the verdict before pass 1, outlier or none", "[rejection]") @@ -1483,15 +1567,22 @@ TEST_CASE("observations fewer than KeepAtLeast give the verdict before pass 1, o TEST_CASE("an observation that cannot be read fails a rejection at its own position", "[rejection]") { - // 1/INT64_MAX g has no kilogram value: the read fails at observation 2, - // which is the sample's own second determination, and the rejection + // 1/(2^127 - 1) g has no kilogram value: the read fails at observation + // 2, which is the sample's own second determination, and the rejection // relays it there. constexpr auto unreadable = - observedOf(rat(40), Rational { 1, std::numeric_limits::max() }, rat(41), rat(40)); + observedOf(rat(40), Rational { 1, std::numeric_limits::max() }, rat(41), rat(40)); constexpr auto failed = formula::checked_evaluate_rejection(observedRejectionOf(sixPercent), unreadable); STATIC_REQUIRE(failed.error().error == formula::ArithmeticError::Overflow); STATIC_REQUIRE(*failed.error().element == 1); + // 1/(2^63 - 1) g, which 64 bits could not read, is read. + auto const read64 = formula::checked_evaluate_rejection( + observedRejectionOf(sixPercent), + observedOf(rat(40), Rational { 1, std::numeric_limits::max() }, rat(41), rat(40))); + REQUIRE(read64.has_value()); + CHECK(read64->rejected().size() == 1); + CHECK(formula::number_of(*read64) == rat(121, 3)); } TEST_CASE("number_of a rejection is the mean of the survivors, and nothing for its verdict", "[rejection]") diff --git a/test/retry_tests.cpp b/test/retry_tests.cpp index 6a932703..90a50847 100644 --- a/test/retry_tests.cpp +++ b/test/retry_tests.cpp @@ -238,16 +238,28 @@ TEST_CASE("a retry of exactly the cap runs every attempt, and one of one or two TEST_CASE("a retry whose exact values outgrow Rational says Overflow at that attempt", "[retry]") { - // The fixture's own fixpoint doubles its denominator every attempt: never - // settling by a strict margin, it passes 2^63 at attempt 53 (zero-based - // 52), before the cap. Reported, never wrapped. + // A step that squares its value: from 3/2 g, w(k) = w(k-1)^2 / 1 g, never + // settling by a strict margin, outgrows 128 bits at attempt 7 (zero-based + // 6), before the cap. Reported, never wrapped. constexpr auto flat = formula::previous_attempt - formula::this_attempt >= formula::constant(rat(0)); + constexpr auto fromThreeHalves = formula::starting_from(formula::constant(rat(3, 2))); + constexpr auto squaring = + formula::previous_attempt * formula::previous_attempt / formula::constant(rat(1)); auto const sixtyFour = - formula::retry(fromZero, halving, flat, repeat, cite); + formula::retry(fromThreeHalves, squaring, flat, repeat, cite); auto const ran = formula::checked_evaluate_retry(sixtyFour, nothing); REQUIRE(!ran.has_value()); - CHECK(ran.error() == formula::RetryFailure { formula::ArithmeticError::Overflow, 52 }); + CHECK(ran.error() == formula::RetryFailure { formula::ArithmeticError::Overflow, 6 }); + + // The fixture's own fixpoint, which doubles its denominator every + // attempt and passed 2^63 at attempt 53, runs all 64 attempts: 2^64 is a + // denominator 128 bits hold. + auto const halvingRun = formula::checked_evaluate_retry( + formula::retry(fromZero, halving, flat, repeat, cite), nothing); + REQUIRE(halvingRun.has_value()); + CHECK(halvingRun->end() == formula::RetryEnd::Exhausted); + CHECK(halvingRun->attempts_made() == 64); } TEST_CASE("a result that is only measured is recomputed, not taken as entered", "[retry]") @@ -450,37 +462,38 @@ std::vector judgements(formula::Trace<> const& record } // The first three attempts of the fixpoint, as the trace shows them: the same -// for the four-attempt retry and the three-attempt one. Computed steps read in -// the coherent unit, as everywhere in a trace. +// for the four-attempt retry and the three-attempt one. Each computed step is a +// mass halved, or a sum or difference of masses in grams, so it reads in grams, +// as the values it is computed from do. constexpr std::string_view firstThreeAttempts = "1. 0 g\n" "2. 152/25 g\n" "3. w(k-1) = 0 g\n" "4. 2\n" - "5. #3 / #4 = 0\n" - "6. #2 + #5 = 19/3125\n" + "5. #3 / #4 = 0 g\n" + "6. #2 + #5 = 152/25 g\n" "7. w(k-1) = 0 g\n" "8. w(k) = 152/25 g\n" - "9. #7 - #8 = -19/3125\n" + "9. #7 - #8 = -152/25 g\n" "10. -19/25 g\n" "11. attempt 1: w(k) = #6 = 152/25 g; judged #9 >= #10: rejected\n" "12. 152/25 g\n" "13. w(k-1) = 152/25 g\n" "14. 2\n" - "15. #13 / #14 = 19/6250\n" - "16. #12 + #15 = 57/6250\n" + "15. #13 / #14 = 76/25 g\n" + "16. #12 + #15 = 228/25 g\n" "17. w(k-1) = 152/25 g\n" "18. w(k) = 228/25 g\n" - "19. #17 - #18 = -19/6250\n" + "19. #17 - #18 = -76/25 g\n" "20. -19/25 g\n" "21. attempt 2: w(k) = #16 = 228/25 g; judged #19 >= #20: rejected\n" "22. 152/25 g\n" "23. w(k-1) = 228/25 g\n" "24. 2\n" - "25. #23 / #24 = 57/12500\n" - "26. #22 + #25 = 133/12500\n" + "25. #23 / #24 = 114/25 g\n" + "26. #22 + #25 = 266/25 g\n" "27. w(k-1) = 228/25 g\n" "28. w(k) = 266/25 g\n" - "29. #27 - #28 = -19/12500\n" + "29. #27 - #28 = -38/25 g\n" "30. -19/25 g\n" "31. attempt 3: w(k) = #26 = 266/25 g; judged #29 >= #30: rejected\n"; } // namespace @@ -505,11 +518,11 @@ TEST_CASE("every attempt of an accepted retry is in the trace, and how it ended" + "32. 152/25 g\n" "33. w(k-1) = 266/25 g\n" "34. 2\n" - "35. #33 / #34 = 133/25000\n" - "36. #32 + #35 = 57/5000\n" + "35. #33 / #34 = 133/25 g\n" + "36. #32 + #35 = 57/5 g\n" "37. w(k-1) = 266/25 g\n" "38. w(k) = 57/5 g\n" - "39. #37 - #38 = -19/25000\n" + "39. #37 - #38 = -19/25 g\n" "40. -19/25 g\n" "41. attempt 4: w(k) = #36 = 57/5 g; judged #39 >= #40: accepted\n" "42. w = retry: accepted at attempt 4 of 4 = 57/5 g [Settled estimate, Example Standard 12, 6]\n"); @@ -550,7 +563,7 @@ TEST_CASE("a failed attempt ends the trace: no step for an attempt that did not "2. k = 1\n" "3. 2\n" "4. #2 - #3 = -1\n" - "5. #1 / #4 = -19/3125\n" + "5. #1 / #4 = -152/25 g\n" "6. w(k) = -152/25 g\n" "7. 103000 g\n" "8. attempt 1: w(k) = #5 = -152/25 g; judged #6 > #7: rejected\n" @@ -674,7 +687,7 @@ TEST_CASE("an absent attempt ends the trace not judgeable, and never accepted", "2. t_w = (not measured)\n" "3. w(k-1) = 0 g\n" "4. 2\n" - "5. #3 / #4 = 0\n" + "5. #3 / #4 = 0 g\n" "6. #2 + #5 = (not measured)\n" "7. attempt 1: w(k) = #6 = (not measured); cannot be judged\n" "8. w = retry: not judgeable at attempt 1 [Settled estimate, Example Standard 12, 6]\n"); @@ -693,8 +706,8 @@ TEST_CASE("an absent judgement ends the trace not judgeable, naming the sides it "2. 152/25 g\n" "3. w(k-1) = 0 g\n" "4. 2\n" - "5. #3 / #4 = 0\n" - "6. #2 + #5 = 19/3125\n" + "5. #3 / #4 = 0 g\n" + "6. #2 + #5 = 152/25 g\n" "7. w(k) = 152/25 g\n" "8. t_w = (not measured)\n" "9. attempt 1: w(k) = #6 = 152/25 g; judged #7 >= #8: cannot be judged\n" @@ -715,8 +728,8 @@ TEST_CASE("a judgement that fails names the failing side, and the attempt keeps "2. 152/25 g\n" "3. w(k-1) = 0 g\n" "4. 2\n" - "5. #3 / #4 = 0\n" - "6. #2 + #5 = 19/3125\n" + "5. #3 / #4 = 0 g\n" + "6. #2 + #5 = 152/25 g\n" "7. w(k) = 152/25 g\n" "8. 1 g\n" "9. k = 1\n" diff --git a/test/rounded_output_tests.cpp b/test/rounded_output_tests.cpp index b82730e6..03f1357b 100644 --- a/test/rounded_output_tests.cpp +++ b/test/rounded_output_tests.cpp @@ -93,8 +93,8 @@ struct ReadingSpan }; // The sum of the draws' reciprocals, with the exact hook: over ten invented -// primes its exact denominator is their product, 75 bits, which no Rational -// holds and four 32-bit limbs do. Counts which route ran. +// primes its exact denominator is their product, 134 bits, which no Rational +// holds and eight 32-bit limbs do. Counts which route ran. struct ReciprocalSum { static constexpr std::string_view name = "reciprocal sum"; @@ -140,7 +140,7 @@ struct ReciprocalSum return std::array { running }; } - static constexpr std::size_t exact_limbs = 4; + static constexpr std::size_t exact_limbs = 8; static constexpr std::expected, 1>, formula::ArithmeticError> compute_exact(std::span draws) noexcept @@ -156,8 +156,8 @@ struct ReciprocalSum if (each.sign() <= 0) return std::unexpected { formula::ArithmeticError::DomainError }; // running + 1 / (p / q) = (running.numerator * p + running.denominator * q) / (running.denominator * p) - Wide const top = Wide::from_u64(static_cast(each.numerator())); - Wide const bottom = Wide::from_u64(static_cast(each.denominator())); + Wide const top = Wide::from_u128(formula::detail::wide_magnitude(each.numerator())); + Wide const bottom = Wide::from_u128(formula::detail::wide_magnitude(each.denominator())); std::optional const kept = formula::detail::mul_checked_or_none(running.numerator, top); std::optional const added = formula::detail::mul_checked_or_none(running.denominator, bottom); std::optional const summed = @@ -195,7 +195,7 @@ struct NarrowReciprocalSum: ReciprocalSum } }; -// The product of two gains, with the exact hook: 2^40 times 2^40 is 2^80, +// The product of two gains, with the exact hook: 2^70 times 2^70 is 2^140, // which the hook holds and no Rational does. struct WideProduct { @@ -220,7 +220,7 @@ struct WideProduct return std::array { *product }; } - static constexpr std::size_t exact_limbs = 4; + static constexpr std::size_t exact_limbs = 8; static constexpr std::expected, 1>, formula::ArithmeticError> compute_exact(formula::Rational multiplicand, formula::Rational multiplier) noexcept @@ -269,22 +269,34 @@ constexpr auto oddTiedReadings = // Five and ten invented primes. The five's reciprocals sum to // 2101205901/58386114749, which Rational holds; the ten's to a fraction over -// a 75-bit denominator, which it does not. +// a 134-bit denominator, which it does not. constexpr auto fiveDraws = formula::environment(formula::measured_series(formula::Measured { rat(103) }, formula::Measured { rat(127) }, formula::Measured { rat(139) }, formula::Measured { rat(163) }, formula::Measured { rat(197) })); -constexpr auto tenDraws = formula::environment(formula::measured_series(formula::Measured { rat(103) }, - formula::Measured { rat(127) }, - formula::Measured { rat(139) }, - formula::Measured { rat(163) }, - formula::Measured { rat(197) }, - formula::Measured { rat(211) }, - formula::Measured { rat(227) }, - formula::Measured { rat(229) }, - formula::Measured { rat(233) }, - formula::Measured { rat(239) })); +constexpr auto tenDraws = formula::environment(formula::measured_series(formula::Measured { rat(10067) }, + formula::Measured { rat(10069) }, + formula::Measured { rat(10079) }, + formula::Measured { rat(10091) }, + formula::Measured { rat(10093) }, + formula::Measured { rat(10099) }, + formula::Measured { rat(10103) }, + formula::Measured { rat(10111) }, + formula::Measured { rat(10133) }, + formula::Measured { rat(10139) })); +// The 75-bit denominator, which 64 bits refused: the ten primes from 103. +constexpr auto tenSmallDraws = + formula::environment(formula::measured_series(formula::Measured { rat(103) }, + formula::Measured { rat(127) }, + formula::Measured { rat(139) }, + formula::Measured { rat(163) }, + formula::Measured { rat(197) }, + formula::Measured { rat(211) }, + formula::Measured { rat(227) }, + formula::Measured { rat(229) }, + formula::Measured { rat(233) }, + formula::Measured { rat(239) })); constexpr auto fiveCall = formula::opaque(reciprocalClause, formula::series); constexpr auto tenCall = formula::opaque(reciprocalClause, formula::series); @@ -448,10 +460,19 @@ TEST_CASE("rounded output: a tie is broken by the mode as checked_round breaks i TEST_CASE("rounded output: the exact hook answers where the exact route overflows", "[rounded-output]") { STATIC_REQUIRE(formula::detail::declares_compute_exact>); - // Ten reciprocals: the exact total needs a 75-bit denominator. + // Ten reciprocals: the exact total needs a 134-bit denominator. auto const exactRoute = formula::checked_evaluate(formula::opaque_output<"total">(tenCall), tenDraws); REQUIRE(!exactRoute.has_value()); CHECK(exactRoute.error() == formula::ArithmeticError::Overflow); + // Over the ten primes from 103, the 75 bits 64 refused: the exact route + // answers, 0.0579755... as the rounded route gave it. + auto const smallExact = + formula::checked_evaluate(formula::opaque_output<"total">(tenCall), tenSmallDraws); + REQUIRE(smallExact.has_value()); + REQUIRE(smallExact->is_value()); + CHECK(formula::checked_round( + smallExact->measurement().value(), formula::DecimalPlaces { 6 }, formula::RoundingMode::HalfEven) + == rat(2319, 40000)); ReciprocalSum::computeCalls = 0; ReciprocalSum::exactCalls = 0; @@ -459,13 +480,14 @@ TEST_CASE("rounded output: the exact hook answers where the exact route overflow formula::rounded_output<"total", unit::One, formula::DecimalPlaces { 6 }, formula::RoundingMode::HalfEven>(tenCall), tenDraws); REQUIRE(rounded.has_value()); - CHECK(rounded->measurement().value() == rat(2319, 40000)); // 0.057975 + CHECK(rounded->measurement().value() == rat(99, 100000)); // 0.000990 CHECK(ReciprocalSum::exactCalls == 1); CHECK(ReciprocalSum::computeCalls == 0); auto const upwards = formula::checked_evaluate( formula::rounded_output<"total", unit::One, formula::DecimalPlaces { 6 }, formula::RoundingMode::Ceiling>(tenCall), tenDraws); - CHECK(upwards->measurement().value() == rat(7247, 125000)); // 0.057976 + REQUIRE(upwards.has_value()); + CHECK(upwards->measurement().value() == rat(991, 1000000)); // 0.000991 } TEST_CASE("rounded output: where both routes answer it is the exact output rounded in every mode", "[rounded-output]") @@ -541,14 +563,14 @@ TEST_CASE("rounded output: the hook's own failure is the operation's", "[rounded TEST_CASE("rounded output: a rounding that fails after the call answered is the output's alone", "[rounded-output]") { constexpr auto productCall = formula::opaque(productClause, formula::var, formula::var); - constexpr std::int64_t twoToForty = std::int64_t { 1 } << 40; + constexpr formula::Rational twoToSeventy { formula::Rational::Int { 1 } << 70 }; auto const huge = - formula::environment(formula::Measured { rat(twoToForty) }, formula::Measured { rat(twoToForty) }); - // The exact route cannot hold 2^80 at all. + formula::environment(formula::Measured { twoToSeventy }, formula::Measured { twoToSeventy }); + // The exact route cannot hold 2^140 at all. auto const plain = formula::checked_evaluate(formula::opaque_output<"product">(productCall), huge); REQUIRE(!plain.has_value()); CHECK(plain.error() == formula::ArithmeticError::Overflow); - // The hook holds it -- the call answers -- but no Rational holds 2^80 + // The hook holds it -- the call answers -- but no Rational holds 2^140 // rounded to units either: the output fails, not the call. auto const called = formula::detail::evaluate_rounded_call<0>(productCall, huge, formula::NullSink {}); CHECK(called.has_value()); @@ -558,15 +580,22 @@ TEST_CASE("rounded output: a rounding that fails after the call answered is the huge); REQUIRE(!rounded.has_value()); CHECK(rounded.error() == formula::ArithmeticError::Overflow); - // The control: 2^40 times 1/2^30 is 1024. - auto const modest = formula::environment(formula::Measured { rat(twoToForty) }, - formula::Measured { rat(1, std::int64_t { 1 } << 30) }); + // The control: 2^70 times 1/2^60 is 1024. + auto const modest = formula::environment(formula::Measured { twoToSeventy }, + formula::Measured { rat(1, std::int64_t { 1 } << 60) }); auto const answered = formula::checked_evaluate( formula::rounded_output<"product", unit::One, formula::DecimalPlaces { 0 }, formula::RoundingMode::HalfEven>( productCall), modest); REQUIRE(answered.has_value()); CHECK(answered->measurement().value() == rat(1024)); + // 2^40 times 2^40, which 64 bits refused, is 2^80 on either route. + constexpr std::int64_t twoToForty = std::int64_t { 1 } << 40; + auto const wide80 = + formula::environment(formula::Measured { rat(twoToForty) }, formula::Measured { rat(twoToForty) }); + auto const exact80 = formula::checked_evaluate(formula::opaque_output<"product">(productCall), wide80); + REQUIRE(exact80.has_value()); + CHECK(exact80->measurement().value() == formula::Rational { formula::Rational::Int { 1 } << 80 }); } TEST_CASE("rounded output: a sink hearing the call is told it holds no values", "[rounded-output]") @@ -655,9 +684,9 @@ TEST_CASE("rounded output: a rounding that fails after the call answered is the "[rounded-output][trace]") { constexpr auto productCall = formula::opaque(productClause, formula::var, formula::var); - constexpr std::int64_t twoToForty = std::int64_t { 1 } << 40; + constexpr formula::Rational twoToSeventy { formula::Rational::Int { 1 } << 70 }; auto const huge = - formula::environment(formula::Measured { rat(twoToForty) }, formula::Measured { rat(twoToForty) }); + formula::environment(formula::Measured { twoToSeventy }, formula::Measured { twoToSeventy }); formula::Trace<> recorded {}; (void) formula::detail::dispatch( formula::rounded_output<"product", unit::One, formula::DecimalPlaces { 0 }, formula::RoundingMode::HalfEven>( @@ -665,8 +694,8 @@ TEST_CASE("rounded output: a rounding that fails after the call answered is the huge, formula::RecordingSink { recorded }); CHECK(formula::render_trace(recorded, { .maxSteps = 20 }) - == "1. g_1 = 1099511627776\n" - "2. g_2 = 1099511627776\n" + == "1. g_1 = 1180591620717411303424\n" + "2. g_2 = 1180591620717411303424\n" "3. wide product(#1, #2) = product: rounded where used [inside not shown] [Product of gains, Example Standard " "7, 2.5]\n" "4. round(product of #3, to 0 dp) = overflow in exact arithmetic [nearest, ties to even]\n"); diff --git a/test/rounded_root_tests.cpp b/test/rounded_root_tests.cpp index 18b48ddb..0ce9a681 100644 --- a/test/rounded_root_tests.cpp +++ b/test/rounded_root_tests.cpp @@ -165,53 +165,71 @@ TEST_CASE("rounded_sqrt of an absent radicand is absent", "[rounded_root]") TEST_CASE("rounded_sqrt reports overflow rather than a wrapped result", "[rounded_root]") { - // An integer radicand of about 10^6 fits at 6 dp and overflows at - // 7 dp, because floor(v) * 10^(2p) must stay below 2^64. 1000001 rather + // An integer radicand of about 10^6 fits at 16 dp and overflows at + // 17 dp, because floor(v) * 10^(2p) must stay below 2^128. 1000001 rather // than 10^6 itself, whose root is exactly 1000 and would take the tie // path, which has headroom of its own. Its root is 1000.000499999875... STATIC_REQUIRE(rootInGrams(Rational { 1'000'001 }) == Rational { 1'000'000'499, 1'000'000 }); - constexpr auto node = formula::rounded_sqrt(var); + // 7 dp, which overflowed 64 bits, and 16 dp, the most that fits. + STATIC_REQUIRE(rootInGrams(Rational { 1'000'001 }) + == Rational { 10'000'004'999, 10'000'000 }); + STATIC_REQUIRE(rootInGrams(Rational { 1'000'001 }) + == Rational { 8'000'003'999'999, 8'000'000'000 }); + constexpr auto node = formula::rounded_sqrt(var); constexpr auto outcome = formula::checked_evaluate(node, variance(Rational { 1'000'001 })); STATIC_REQUIRE(!outcome.has_value()); STATIC_REQUIRE(outcome.error() == formula::ArithmeticError::Overflow); } -TEST_CASE("rounded_sqrt reports overflow at the exact 2^64 edge of the whole part", "[rounded_root]") +TEST_CASE("rounded_sqrt reports overflow at the exact 2^128 edge of the whole part", "[rounded_root]") { - // v * 10^4 for 1844674407370955161/1000 is 18446744073709551610, six - // below 2^64: it fits, and its root rounds down to 42949672.95. One more in - // the numerator puts v * 10^4 at 2^64 + 4. There floor(v) * 10^4 still - // fits and it is adding the remainder's share that crosses, so this pins - // the check on that addition, which the case above never reaches. + // v * 10^4 for N/1000, with N = 34028236692093846346337460743176821145 + // (the largest Int over 5, which is 2^128 / 10 rounded down), is + // 2^128 - 6: it fits, and its root rounds down to (2^64 - 1) / 100. One + // more in the numerator puts v * 10^4 at 2^128 + 4. There floor(v) * 10^4 + // still fits and it is adding the remainder's share that crosses, so this + // pins the check on that addition, which the case above never reaches. using formula::detail::rounded_square_root; + constexpr Rational::Int tenthOfTop = std::numeric_limits::max() / 5; + STATIC_REQUIRE(rounded_square_root(Rational { tenthOfTop, 1000 }, DecimalPlaces { 2 }, RoundingMode::Floor).value() + == Rational { 3'689'348'814'741'910'323, 20 }); + STATIC_REQUIRE(rounded_square_root(Rational { tenthOfTop + 1, 1000 }, DecimalPlaces { 2 }, RoundingMode::Floor).error() + == formula::ArithmeticError::Overflow); + // The same edge at 2^64, which 64 bits refused one past: the root of + // 2^64 + 4 rounds down to 2^32 / 100. STATIC_REQUIRE( rounded_square_root(Rational { 1'844'674'407'370'955'161, 1000 }, DecimalPlaces { 2 }, RoundingMode::Floor).value() == Rational { 858'993'459, 20 }); STATIC_REQUIRE( - rounded_square_root(Rational { 1'844'674'407'370'955'162, 1000 }, DecimalPlaces { 2 }, RoundingMode::Floor).error() - == formula::ArithmeticError::Overflow); + rounded_square_root(Rational { 1'844'674'407'370'955'162, 1000 }, DecimalPlaces { 2 }, RoundingMode::Floor).value() + == Rational { 1'073'741'824, 25 }); } -TEST_CASE("rounded_sqrt never wraps when a negative number of places widens the denominator past 2^64", "[rounded_root]") +TEST_CASE("rounded_sqrt never wraps when a negative number of places widens the denominator past 2^128", "[rounded_root]") { - // At -1 places the divisor is b * 10^2. With b = 184467440737095517, the - // smallest b for which that product reaches 2^64, a wrapping multiply - // leaves 84 -- and dividing 10^18 by 84 instead of by b * 100 answers - // 1091089460 where the root of 10^18 / b (about 5.42) is 2.33, 10 to the - // next ten up. Today the guard answers Overflow; were the headroom ever - // widened, the true 10 would be right too. Only a wrapped value is wrong, - // and that is all this pins. + // At -1 places the divisor is b * 10^2. With b just above 2^128 / 100, + // and sharing no factor with 10, that product leaves 128 bits: a + // wrapping multiply would divide 10^18 by a small number instead, and + // answer a wrong root. The guard answers Overflow. using formula::detail::rounded_square_root; - constexpr auto wide = Rational::make(1'000'000'000'000'000'000, 184'467'440'737'095'517).value(); - constexpr auto rooted = rounded_square_root(wide, DecimalPlaces { -1 }, RoundingMode::Ceiling); - STATIC_REQUIRE(rooted.has_value() ? *rooted == Rational { 10 } : rooted.error() == formula::ArithmeticError::Overflow); + constexpr Rational::Int pastTop = std::numeric_limits::max() / 50 + 3; + constexpr auto wide = Rational::make(1'000'000'000'000'000'000, pastTop); + STATIC_REQUIRE(wide.has_value()); + STATIC_REQUIRE(rounded_square_root(*wide, DecimalPlaces { -1 }, RoundingMode::Ceiling).error() + == formula::ArithmeticError::Overflow); + // With b = 184467440737095517, the smallest b for which b * 100 reaches + // 2^64, the root of 10^18 / b (about 5.42) is 2.33, 10 to the next ten + // up -- which 64 bits refused. + constexpr auto wide64 = Rational::make(1'000'000'000'000'000'000, 184'467'440'737'095'517); + STATIC_REQUIRE(wide64.has_value()); + STATIC_REQUIRE(rounded_square_root(*wide64, DecimalPlaces { -1 }, RoundingMode::Ceiling).value() == Rational { 10 }); } -TEST_CASE("rounded_sqrt runs its 64-bit path at runtime too", "[rounded_root]") +TEST_CASE("rounded_sqrt runs its 128-bit path at runtime too", "[rounded_root]") { // Every case above is a STATIC_REQUIRE, evaluated by the compiler, so a - // sanitizer never sees the uint64 arithmetic execute. These run it: the + // sanitizer never sees the 128-bit arithmetic execute. These run it: the // table is read at runtime, so each call happens there, in clang-ubsan as // everywhere else. struct Case @@ -221,7 +239,7 @@ TEST_CASE("rounded_sqrt runs its 64-bit path at runtime too", "[rounded_root]") RoundingMode mode; Rational expected; }; - std::array const cases { { + std::array const cases { { { Rational { 427, 125 }, DecimalPlaces { 2 }, RoundingMode::HalfAwayFromZero, Rational { 37, 20 } }, { Rational { 4057, 600 }, DecimalPlaces { 2 }, RoundingMode::Ceiling, Rational { 261, 100 } }, { Rational { 9, 4 }, DecimalPlaces { 0 }, RoundingMode::HalfTowardZero, Rational { 1 } }, @@ -232,6 +250,10 @@ TEST_CASE("rounded_sqrt runs its 64-bit path at runtime too", "[rounded_root]") DecimalPlaces { 2 }, RoundingMode::Floor, Rational { 858'993'459, 20 } }, + { Rational { 1'844'674'407'370'955'162, 1000 }, + DecimalPlaces { 2 }, + RoundingMode::Floor, + Rational { 1'073'741'824, 25 } }, } }; for (Case const& each: cases) { @@ -241,7 +263,7 @@ TEST_CASE("rounded_sqrt runs its 64-bit path at runtime too", "[rounded_root]") } auto const wrapped = formula::detail::rounded_square_root( - Rational { 1'844'674'407'370'955'162, 1000 }, DecimalPlaces { 2 }, RoundingMode::Floor); + Rational { std::numeric_limits::max() / 5 + 1, 1000 }, DecimalPlaces { 2 }, RoundingMode::Floor); REQUIRE(!wrapped.has_value()); CHECK(wrapped.error() == formula::ArithmeticError::Overflow); diff --git a/test/rounded_transcendental_tests.cpp b/test/rounded_transcendental_tests.cpp index 04f924e2..4b2adb4e 100644 --- a/test/rounded_transcendental_tests.cpp +++ b/test/rounded_transcendental_tests.cpp @@ -127,12 +127,13 @@ TEST_CASE("rounded_transcendental: an exponential too large to hold is Overflow" { // exp 50 = 5.18 * 10^21: past the early bound. STATIC_REQUIRE(expAt(Rational { 50 }) == overflow); - // exp 43.7 = 9.52 * 10^18: through the kernel. Floor to whole 10^18s keeps 9 * 10^18, which fits; - // the nearest modes give 10^19, which does not. + // exp 43.7 = 9.52 * 10^18: through the kernel. Floor to whole 10^18s keeps 9 * 10^18; the nearest + // modes give 10^19, which overflowed 64 bits and fits 128. CHECK(expAt(Rational { 437, 10 }) == Rational { 9'000'000'000'000'000'000 }); - CHECK(expAt(Rational { 437, 10 }) == overflow); - // exp 44 = 1.29 * 10^19 fits at no places. - CHECK(expAt(Rational { 44 }) == overflow); + CHECK(expAt(Rational { 437, 10 }) + == Rational { 10'000'000'000'000'000'000ULL }); + // exp 44 = 1.29 * 10^19, which fitted at no places in 64 bits, floors to 12 * 10^18. + CHECK(expAt(Rational { 44 }) == Rational { 12'000'000'000'000'000'000ULL }); // exp 43 = 4727839468229346561.47...: whole, it fits. CHECK(expAt(Rational { 43 }) == Rational { 4'727'839'468'229'346'561 }); @@ -182,6 +183,26 @@ TEST_CASE("rounded_transcendental: absence and failures come first and in order" STATIC_REQUIRE(expAt(Rational { -50 }) == overflow); STATIC_REQUIRE(expAt(Rational { -50 }) == overflow); STATIC_REQUIRE(expAt(Rational { -50 }) == overflow); + // An argument whose numerator or denominator does not fit 64 bits is beyond the kernel, which works on + // two values below 2^63: ln 2^70, exp 2^-64 and log10 2^70 are Overflow, though a Rational holds each + // argument. + STATIC_REQUIRE(lnAt(Rational { Rational::Int { 1 } << 70 }) + == overflow); + STATIC_REQUIRE(expAt(Rational { 1, Rational::Int { 1 } << 64 }) + == overflow); + STATIC_REQUIRE(log10At(Rational { Rational::Int { 1 } << 70 }) + == overflow); + // A wide power of ten is a special point, answered before the kernel is asked: log10 10^30 is 30. + constexpr Rational::Int tenToFifteen = 1'000'000'000'000'000; + STATIC_REQUIRE(log10At(Rational { tenToFifteen * tenToFifteen }) + == Rational { 30 }); + // The rule below -43 comes first, whatever the argument's width: exp -2^70 is 0. + STATIC_REQUIRE(expAt(Rational { -(Rational::Int { 1 } << 70) }) + == Rational {}); + // 2^62 and 1/2^62, inside it, answer: ln 2^62 = 42.97512..., exp 2^-62 rounds to 1. + CHECK(lnAt(Rational { Rational::Int { 1 } << 62 }) + == Rational { 429751, 10000 }); + CHECK(expAt(Rational { 1, Rational::Int { 1 } << 62 }) == Rational { 1 }); } TEST_CASE("rounded_transcendental: a percentage is read in the coherent unit", "[rounded_transcendental]") @@ -206,8 +227,9 @@ TEST_CASE("rounded_transcendental: a value a double cannot tell from a boundary CHECK(log10At(Rational { 999'999'999'999'999'999 }) == Rational::from_decimal(1'799'999'999'999'999'999, -17)); CHECK(log10At(Rational { 999'999'999'999'999'999 }) == Rational { 18 }); - // At 18 places the result, 1.8 * 10^19 in units of 10^-18, does not fit a Rational. - CHECK(log10At(Rational { 999'999'999'999'999'999 }) == overflow); + // At 18 places the result, 1.8 * 10^19 in units of 10^-18, overflowed 64 bits; it fits 128. + CHECK(log10At(Rational { 999'999'999'999'999'999 }) + == Rational { 17'999'999'999'999'999'999ULL, 1'000'000'000'000'000'000 }); } TEST_CASE("rounded_transcendental: a rounding the kernel cannot decide is Overflow", "[rounded_transcendental]") diff --git a/test/rounding_tests.cpp b/test/rounding_tests.cpp index cf2fe300..8e1dd401 100644 --- a/test/rounding_tests.cpp +++ b/test/rounding_tests.cpp @@ -293,20 +293,44 @@ TEST_CASE("rounding precision is limited by the numerator, not the magnitude", " // Rounding to N places scales by 10^N, cancelling factors of two against // the denominator first, so what must fit is // |numerator| * (10^N / gcd(10^N, denominator)), - // which for a binary denominator is |numerator| * 5^N. The NUMERATOR decides. + // which for a binary denominator is |numerator| * 5^N. The NUMERATOR + // decides. A double below 2^53 in magnitude has one of at most 53 bits, + // and rounding it at up to 18 places forms at most 2^53 * 5^18 * 2^18, + // below 2^113, so every place from 0 to 18 fits. - // 0.45 as a double is exactly 8106479329266893 / 2^54 -- a 53-bit numerator. - // Two places fits (8106479329266893 * 5^2), six does not (* 5^6). + // 0.45 as a double is exactly 8106479329266893 / 2^54 -- a 53-bit + // numerator. Six places, which overflowed 64 bits, and eighteen fit. CHECK(formula::rational_from_double(0.45, formula::DecimalPlaces { 2 }, RoundingMode::HalfAwayFromZero).has_value()); - CHECK(error_of(formula::rational_from_double(0.45, formula::DecimalPlaces { 6 }, RoundingMode::HalfAwayFromZero)) + CHECK(formula::rational_from_double(0.45, formula::DecimalPlaces { 6 }, RoundingMode::HalfAwayFromZero) + == Rational { 9, 20 }); + CHECK(formula::rational_from_double(0.45, formula::DecimalPlaces { 18 }, RoundingMode::HalfAwayFromZero) + == Rational { 450'000'000'000'000'011, 1'000'000'000'000'000'000 }); + // Just below 2^52, half a unit off a whole number, it is exact at 18 places. + CHECK(formula::rational_from_double(4503599627370495.5, formula::DecimalPlaces { 18 }, RoundingMode::Floor) + == Rational { 9007199254740991, 2 }); + // And 2^53 - 1, the largest whole number below 2^53, is exact at 18. + CHECK(formula::rational_from_double(9007199254740991.0, formula::DecimalPlaces { 18 }, RoundingMode::Floor) + == Rational { 9007199254740991 }); + // Past 2^53 the claim stops: a whole double's numerator is its own + // magnitude, with nothing to cancel, so 1e21 overflows at 18 places and + // 1e38 at 1; and at -18 places 2^-100's denominator times 10^18 does. + CHECK(error_of(formula::rational_from_double(1e21, formula::DecimalPlaces { 18 }, RoundingMode::Floor)) + == ArithmeticError::Overflow); + CHECK(error_of(formula::rational_from_double(1e38, formula::DecimalPlaces { 1 }, RoundingMode::Floor)) + == ArithmeticError::Overflow); + CHECK(error_of(formula::rational_from_double(0x1p-100, formula::DecimalPlaces { -18 }, RoundingMode::Floor)) == ArithmeticError::Overflow); - // 0.0001 fails for a DIFFERENT reason: from_double_exact refuses it before - // any rounding happens, because its exact value needs a denominator above - // 2^63. That limit really is magnitude-driven; the one above is not. - CHECK(error_of(Rational::from_double_exact(0.0001)) == ArithmeticError::Overflow); - CHECK(error_of(formula::rational_from_double(0.0001, formula::DecimalPlaces { 4 }, RoundingMode::HalfAwayFromZero)) + // What does fail is from_double_exact, before any rounding happens, when + // the double's exact value needs a denominator of 2^127 or more: 1e-30 is + // m / 2^147. That limit really is magnitude-driven. 0.0001, m / 2^66, + // which needed more than 64 bits, is read. + CHECK(error_of(Rational::from_double_exact(1e-30)) == ArithmeticError::Overflow); + CHECK(error_of(formula::rational_from_double(1e-30, formula::DecimalPlaces { 4 }, RoundingMode::HalfAwayFromZero)) == ArithmeticError::Overflow); + CHECK(Rational::from_double_exact(0.0001) == Rational { 7'378'697'629'483'821, Rational::Int { 1 } << 66 }); + CHECK(formula::rational_from_double(0.0001, formula::DecimalPlaces { 4 }, RoundingMode::HalfAwayFromZero) + == Rational { 1, 10'000 }); // Same nominal value, built exactly: numerator 1, so ten places is trivial. // Same value, different construction, different outcome -- the point. @@ -314,14 +338,21 @@ TEST_CASE("rounding precision is limited by the numerator, not the magnitude", " *Rational::from_decimal(1, -4), formula::DecimalPlaces { 10 }, RoundingMode::HalfAwayFromZero) .has_value()); - // The denominator is not what limits it: numerator 1 over 2^60 rounds at - // every supported place, while a 53-bit numerator over the SAME denominator - // does not. This pair is what distinguishes the two explanations. - CHECK(formula::checked_round(*Rational::make(1, 1LL << 60), formula::DecimalPlaces { 18 }, RoundingMode::Floor) + // The denominator is not what limits it: numerator 1 over 2^121 rounds at + // every supported place, while a 100-bit numerator over the SAME + // denominator does not. This pair is what distinguishes the two + // explanations. + constexpr Rational::Int twoTo121 = Rational::Int { 1 } << 121; + CHECK(formula::checked_round(Rational { 1, twoTo121 }, formula::DecimalPlaces { 18 }, RoundingMode::Floor) .has_value()); CHECK(error_of(formula::checked_round( - *Rational::make(8106479329266893LL, 1LL << 60), formula::DecimalPlaces { 18 }, RoundingMode::Floor)) + Rational { (Rational::Int { 1 } << 100) - 1, twoTo121 }, formula::DecimalPlaces { 18 }, RoundingMode::Floor)) == ArithmeticError::Overflow); + // A 53-bit numerator over 2^60, which overflowed 64 bits, rounds: + // 0.00703125000000000006... floors to 0.007031250000000000. + CHECK(formula::checked_round( + Rational { 8106479329266893LL, 1LL << 60 }, formula::DecimalPlaces { 18 }, RoundingMode::Floor) + == Rational { 9, 1280 }); // A small-denominator, small-numerator Rational is unaffected throughout. CHECK(formula::checked_round(*Rational::make(1, 3), formula::DecimalPlaces { 10 }, RoundingMode::Floor).has_value()); diff --git a/test/series_tests.cpp b/test/series_tests.cpp index ce2cebdc..dfa1d781 100644 --- a/test/series_tests.cpp +++ b/test/series_tests.cpp @@ -196,12 +196,12 @@ TEST_CASE("an element that cannot be read into SI fails the whole series and nam // Tonnes become kilograms by multiplying by 1000, and the middle element // is too large for that. Its neighbours are fine, so a failure reported at // 0 or at the last element, or a partial series, is the wrong answer. - constexpr std::int64_t tooLarge = std::numeric_limits::max() / 100; - constexpr auto overflowing = - formula::environment(formula::measured_series(formula::Measured { rat(1) }, - formula::Measured { rat(2) }, - formula::Measured { rat(tooLarge) }, - formula::Measured { rat(3) })); + constexpr formula::Rational::Int tooLarge = std::numeric_limits::max() / 100; + constexpr auto overflowing = formula::environment( + formula::measured_series(formula::Measured { rat(1) }, + formula::Measured { rat(2) }, + formula::Measured { formula::Rational { tooLarge } }, + formula::Measured { rat(3) })); constexpr auto si = formula::detail::dispatch_series( formula::series, overflowing, formula::NullSink {}); @@ -213,6 +213,19 @@ TEST_CASE("an element that cannot be read into SI fails the whole series and nam constexpr auto outcome = formula::checked_evaluate_series(formula::series, overflowing); STATIC_REQUIRE(!outcome.has_value()); STATIC_REQUIRE(outcome.error() == formula::SeriesFailure { formula::ArithmeticError::Overflow, 2 }); + + // A hundredth of the largest 64-bit integer, which overflowed 64 bits, + // reads into kilograms. + constexpr std::int64_t largeIn64 = std::numeric_limits::max() / 100; + constexpr auto fitting = + formula::environment(formula::measured_series(formula::Measured { rat(1) }, + formula::Measured { rat(2) }, + formula::Measured { rat(largeIn64) }, + formula::Measured { rat(3) })); + constexpr auto fittingSi = formula::detail::dispatch_series( + formula::series, fitting, formula::NullSink {}); + STATIC_REQUIRE(fittingSi.has_value()); + STATIC_REQUIRE(fittingSi->elements[2] == formula::Rational { formula::Rational::Int { largeIn64 } * 1000 }); } TEST_CASE("an element that cannot be written back in the declared unit names that element", "[series]") @@ -220,11 +233,11 @@ TEST_CASE("an element that cannot be written back in the declared unit names tha // Read as Stockpile (tonnes) and reported as Retained (grams): reading // into kilograms multiplies by 1000 and still fits, and the way back into // grams multiplies by 1000 again, which only element 1 is too large for. - constexpr std::int64_t tooLarge = std::numeric_limits::max() / 10'000; - constexpr auto large = - formula::environment(formula::measured_series(formula::Measured { rat(1) }, - formula::Measured { rat(tooLarge) }, - formula::Measured { rat(2) })); + constexpr formula::Rational::Int tooLarge = std::numeric_limits::max() / 10'000; + constexpr auto large = formula::environment( + formula::measured_series(formula::Measured { rat(1) }, + formula::Measured { formula::Rational { tooLarge } }, + formula::Measured { rat(2) })); constexpr auto si = formula::detail::dispatch_series(formula::series, large, formula::NullSink {}); @@ -578,7 +591,8 @@ namespace running { }; - constexpr std::int64_t halfLimit = std::numeric_limits::max() / 2 + 1; + constexpr formula::Rational::Int halfLimit = std::numeric_limits::max() / 2 + 1; + constexpr formula::Rational halfLimitValue { halfLimit }; // Two elements just over half of Rational's limit, at zero-based 1 and 2 // of five -- off the centre, so that neither end is where it fails and a @@ -587,8 +601,8 @@ namespace running // element 1, the third addition. constexpr auto nearTheLimit = formula::environment(formula::measured_series(formula::Measured { rat(1) }, - formula::Measured { rat(halfLimit) }, - formula::Measured { rat(halfLimit) }, + formula::Measured { formula::Rational { halfLimit } }, + formula::Measured { formula::Rational { halfLimit } }, formula::Measured { rat(2) }, formula::Measured { rat(3) })); @@ -745,19 +759,34 @@ TEST_CASE("a running total that overflows fails at the element where it overflow STATIC_REQUIRE(!total.has_value()); STATIC_REQUIRE(total.error() == formula::ArithmeticError::Overflow); + // Two halves of the 64-bit limit, which overflowed 64 bits, add up: 1, + // 2^62, 2^62, 2 and 3 total 2^63 + 6. + constexpr std::int64_t halfLimit64 = std::numeric_limits::max() / 2 + 1; + constexpr auto nearTheLimit64 = + formula::environment(formula::measured_series(formula::Measured { rat(1) }, + formula::Measured { rat(halfLimit64) }, + formula::Measured { rat(halfLimit64) }, + formula::Measured { rat(2) }, + formula::Measured { rat(3) })); + constexpr auto total64 = formula::checked_evaluate_si(formula::sum(s), nearTheLimit64); + STATIC_REQUIRE(total64.has_value()); + STATIC_REQUIRE(total64->has_value()); + STATIC_REQUIRE(**total64 == formula::Rational { (formula::Rational::Int { 1 } << 63) + 6 }); + // Absence is judged over the whole series first, so where the gap is // plays no part: absent with the gap after the two // elements whose addition overflows, and absent with it before them. using running::halfLimit; - constexpr auto gapAfter = formula::environment(formula::measured_series(formula::Measured { rat(1) }, - formula::Measured { rat(halfLimit) }, - formula::Measured { rat(halfLimit) }, - formula::Measured { rat(2) }, - formula::Measured::absent())); + constexpr auto gapAfter = + formula::environment(formula::measured_series(formula::Measured { rat(1) }, + formula::Measured { formula::Rational { halfLimit } }, + formula::Measured { formula::Rational { halfLimit } }, + formula::Measured { rat(2) }, + formula::Measured::absent())); constexpr auto gapBefore = formula::environment(formula::measured_series(formula::Measured::absent(), - formula::Measured { rat(halfLimit) }, - formula::Measured { rat(halfLimit) }, + formula::Measured { formula::Rational { halfLimit } }, + formula::Measured { formula::Rational { halfLimit } }, formula::Measured { rat(2) }, formula::Measured { rat(3) })); constexpr auto totalGapAfter = formula::checked_evaluate_si(formula::sum(s), gapAfter); @@ -924,7 +953,7 @@ TEST_CASE("a per-element rounding keeps absence, and names the element a failure using running::Load; constexpr auto heavy = formula::environment(formula::measured_series(formula::Measured { rat(1) }, - formula::Measured { rat(running::halfLimit) }, + formula::Measured { running::halfLimitValue }, formula::Measured { rat(2) })); constexpr formula::PlacesTable<3> wholeGrams { formula::DecimalPlaces { 0 }, formula::DecimalPlaces { 0 }, diff --git a/test/snap_tests.cpp b/test/snap_tests.cpp index a5ea54c0..9bb72352 100644 --- a/test/snap_tests.cpp +++ b/test/snap_tests.cpp @@ -123,18 +123,23 @@ TEST_CASE("a value in another unit is converted into the key unit before it is c TEST_CASE("distances are taken in the key unit, where a form in SI would overflow", "[snap]") { - // Invented at Rational's limit, in millimetres: the first permitted value - // is 1/10^16 mm, which is 1/10^19 m -- a denominator no int64 holds. In - // the key unit every distance is exact, and 113 mm (given in metres) - // snaps to 103 mm; a snap that compared in SI would have to convert that - // value and could only fail. Equivalent wherever both forms can be - // represented; told apart here. - constexpr formula::BreakpointTable<3> atTheLimit { breakpoint(1, 10'000'000'000'000'000), - breakpoint(103), - breakpoint(127) }; + // Invented at Rational's limit: the opening is 113 * 10^33 / (10^36 - 1) m, + // a hair above 113 mm, with a denominator prime to ten. In millimetres its + // distances to the neighbours 103 mm and 127 mm keep that denominator and + // are exact, and it snaps to 103 mm. In metres the distance to 103/1000 m + // needs the denominator 1000 * (10^36 - 1), past the 2^127 a Rational + // holds: a snap that compared in SI could only fail. Equivalent wherever + // both forms can be represented; told apart here. + constexpr formula::Rational::Int tenToEighteen = 1'000'000'000'000'000'000; + constexpr formula::Rational opening { formula::Rational::Int { 113'000'000'000'000'000 } * tenToEighteen, + tenToEighteen * tenToEighteen - 1 }; + constexpr auto distanceInSi = formula::checked_sub(opening, rat(103, 1000)); + STATIC_REQUIRE(!distanceInSi.has_value()); + STATIC_REQUIRE(distanceInSi.error() == formula::ArithmeticError::Overflow); + constexpr formula::BreakpointTable<2> neighbours { breakpoint(103), breakpoint(127) }; constexpr auto snapped = - formula::checked_evaluate(formula::snapped(formula::var), - formula::environment(formula::Measured { rat(113, 1000) })); + formula::checked_evaluate(formula::snapped(formula::var), + formula::environment(formula::Measured { opening })); STATIC_REQUIRE(snapped.has_value()); STATIC_REQUIRE(snapped->measurement().value() == rat(103, 1000)); } diff --git a/test/statistics_tests.cpp b/test/statistics_tests.cpp index bc72e31c..b3102951 100644 --- a/test/statistics_tests.cpp +++ b/test/statistics_tests.cpp @@ -186,7 +186,7 @@ TEST_CASE("a total that overflows fails the mean, and the trace names the determ // The largest Rational, then 1 kg: the total overflows at the second // determination. Never a wrapped, negative mean. constexpr auto heavy = formula::environment( - formula::measured_series(formula::Measured { Rational { std::numeric_limits::max() } }, + formula::measured_series(formula::Measured { Rational { std::numeric_limits::max() } }, formula::Measured { rat(1) }, formula::Measured { rat(2) })); constexpr auto mean = formula::sample_mean(formula::series); @@ -195,8 +195,17 @@ TEST_CASE("a total that overflows fails the mean, and the trace names the determ formula::Trace<> trace {}; (void) formula::checked_evaluate(mean, heavy, formula::RecordingSink<> { trace }); CHECK(formula::render_trace(trace, { .maxSteps = 10 }) - == "1. m_h = 9223372036854775807 kg; 1 kg; 2 kg\n" + == "1. m_h = 170141183460469231731687303715884105727 kg; 1 kg; 2 kg\n" "2. sample_mean(#1) = overflow in exact arithmetic at element 2\n"); + + // The largest 64-bit integer, which overflowed 64 bits, then 1 kg and + // 2 kg: a mean of (2^63 + 2)/3 kg. + constexpr auto heavy64 = formula::environment( + formula::measured_series(formula::Measured { Rational { std::numeric_limits::max() } }, + formula::Measured { rat(1) }, + formula::Measured { rat(2) })); + STATIC_REQUIRE(formula::checked_evaluate(mean, heavy64)->measurement().value() + == Rational { (Rational::Int { 1 } << 63) + 2, 3 }); } TEST_CASE("sample statistics evaluate at runtime, and in double", "[statistics]") @@ -296,7 +305,7 @@ TEST_CASE("a failure position is amended only onto a failed statistic, and only "[statistics][trace-render]") { constexpr auto heavy = formula::environment( - formula::measured_series(formula::Measured { Rational { std::numeric_limits::max() } }, + formula::measured_series(formula::Measured { Rational { std::numeric_limits::max() } }, formula::Measured { rat(1) }, formula::Measured { rat(2) })); formula::Trace<> trace {}; @@ -306,7 +315,7 @@ TEST_CASE("a failure position is amended only onto a failed statistic, and only // takes it, being a failed mean, and the renderer declines to print it. sink.sample_failed_at(4); CHECK(formula::render_trace(trace, { .maxSteps = 10 }) - == "1. m_h = 9223372036854775807 kg; 1 kg; 2 kg\n" + == "1. m_h = 170141183460469231731687303715884105727 kg; 1 kg; 2 kg\n" "2. sample_mean(#1) = overflow in exact arithmetic at (no such element)\n"); // A present mean is never given a failure position. @@ -398,6 +407,20 @@ TEST_CASE("equal determinations have no dispersion, exactly", "[statistics]") == rat(0)); } +TEST_CASE("the variance of six masses read to the microgram answers exactly", "[statistics]") +{ + // The sample that overflowed 64 bits in kg^2: in g^2 its variance is + // 2026588050217/6000000000000, exactly. + auto const sixAtMicrograms = formula::environment(formula::measured_series( + grams(rat(40053270, 1000000)), grams(rat(39475922, 1000000)), grams(rat(39025798, 1000000)), + grams(rat(40615904, 1000000)), grams(rat(39418416, 1000000)), grams(rat(40131659, 1000000)))); + auto const microgramVariance = formula::checked_evaluate( + formula::sample_variance(formula::series), sixAtMicrograms); + REQUIRE(microgramVariance.has_value()); + REQUIRE(microgramVariance->is_value()); + CHECK(microgramVariance->measurement().value() == formula::Rational { 2026588050217, 6000000000000 }); +} + TEST_CASE("one determination has a range of 0 and no variance", "[statistics]") { // A variance needs two determinations: n - 1 = 0 is refused as a domain @@ -417,65 +440,148 @@ TEST_CASE("one absent determination makes the variance and the range absent", "[ STATIC_REQUIRE(formula::checked_evaluate(range, fixtureAMissingThird)->is_empty()); } +namespace +{ +/// The textbook one-pass sample variance, (sum x^2 - (sum x)^2 / n) / (n - 1), +/// of @p kilograms, in exact arithmetic: the form `sample_variance` does not +/// use, kept here to show where it runs out. +[[nodiscard]] constexpr std::expected one_pass_variance( + std::span kilograms) noexcept +{ + Rational massTotal { 0 }; + Rational squaresTotal { 0 }; + for (Rational const& mass: kilograms) + { + std::expected const squared = formula::checked_mul(mass, mass); + if (!squared) + return std::unexpected { squared.error() }; + std::expected const squaresSoFar = formula::checked_add(squaresTotal, *squared); + if (!squaresSoFar) + return std::unexpected { squaresSoFar.error() }; + std::expected const massSoFar = formula::checked_add(massTotal, mass); + if (!massSoFar) + return std::unexpected { massSoFar.error() }; + squaresTotal = *squaresSoFar; + massTotal = *massSoFar; + } + auto const sizeOf = static_cast(kilograms.size()); + std::expected const totalSquared = formula::checked_mul(massTotal, massTotal); + if (!totalSquared) + return std::unexpected { totalSquared.error() }; + std::expected const overSize = formula::checked_div(*totalSquared, rat(sizeOf)); + if (!overSize) + return std::unexpected { overSize.error() }; + std::expected const deviations = formula::checked_sub(squaresTotal, *overSize); + if (!deviations) + return std::unexpected { deviations.error() }; + return formula::checked_div(*deviations, rat(sizeOf - 1)); +} + +/// Fixture A scaled by @p scaleBy, in kilograms. +[[nodiscard]] constexpr std::array fixture_a_kilograms(Rational::Int scaleBy) noexcept +{ + return { Rational { 402 * scaleBy, 10'000 }, Rational { 398 * scaleBy, 10'000 }, Rational { 405 * scaleBy, 10'000 }, + Rational { 440 * scaleBy, 10'000 }, Rational { 400 * scaleBy, 10'000 }, Rational { 433 * scaleBy, 10'000 } }; +} + +/// Fixture A scaled by @p scaleBy, as the evaluator reads it: in grams. +[[nodiscard]] constexpr auto fixture_a_scaled(Rational::Int scaleBy) noexcept +{ + return formula::environment(formula::measured_series(grams(Rational { 402 * scaleBy, 10 }), + grams(Rational { 398 * scaleBy, 10 }), + grams(Rational { 405 * scaleBy, 10 }), + grams(Rational { 440 * scaleBy, 10 }), + grams(Rational { 400 * scaleBy, 10 }), + grams(Rational { 433 * scaleBy, 10 }))); +} +} // namespace + TEST_CASE("at large magnitudes the two-pass variance holds past where the one-pass formula overflows", "[statistics]") { - // Fixture A scaled by 2^25: 40.2 g becomes 1348888166.4 g. The textbook - // one-pass form, (sum x^2 - (sum x)^2 / n) / (n - 1), overflows Rational - // in coherent SI at a scale of 2^25; the two-pass form (the mean, then - // the squared deviations from it) holds until 2^31 -- and gives exactly - // 427/125 g^2 times 2^50, in kg^2. Measured with fixtures A and B - // alike; at 10^4, the brief's first guess, neither overflows (the forms - // part only between 10^11 and 10^12 when scaled by powers of ten). At - // fine resolution it is the other way round -- see the header. - constexpr std::int64_t scale = std::int64_t { 1 } << 25; - constexpr auto scaledA = formula::environment(formula::measured_series(grams(rat(402 * scale, 10)), - grams(rat(398 * scale, 10)), - grams(rat(405 * scale, 10)), - grams(rat(440 * scale, 10)), - grams(rat(400 * scale, 10)), - grams(rat(433 * scale, 10)))); - STATIC_REQUIRE(formula::checked_evaluate_si(variance, scaledA)->value() - == rat(427 * (std::int64_t { 1 } << 44), 1'953'125)); + // Fixture A scaled by 2^57: 40.2 g becomes about 5.8e18 g. The textbook + // one-pass form, (sum x^2 - (sum x)^2 / n) / (n - 1), computed exactly in + // kg, first overflows at a scale of 2^57, where the square of the + // masses' sum, 247.8 g times 2^57, outgrows 127 bits. At 2^56 it still + // answers, and agrees with the two-pass form. The two-pass form (the mean, then the + // squared deviations from it) squares only the deviations, and holds up + // to 2^62 (see the overflow test below) -- at 2^57 it gives exactly + // 427/125 g^2 times 2^114, in kg^2. At fine resolution it is the other + // way round -- see the header. + constexpr Rational::Int below = Rational::Int { 1 } << 56; + constexpr Rational::Int scale = Rational::Int { 1 } << 57; + STATIC_REQUIRE(one_pass_variance(fixture_a_kilograms(below)).value() + == Rational { Rational::Int { 427 } << 106, 1'953'125 }); + STATIC_REQUIRE(formula::checked_evaluate_si(variance, fixture_a_scaled(below))->value() + == Rational { Rational::Int { 427 } << 106, 1'953'125 }); + STATIC_REQUIRE(one_pass_variance(fixture_a_kilograms(scale)).error() == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(formula::checked_mul(Rational { 2478 * scale, 10'000 }, Rational { 2478 * scale, 10'000 }).error() + == formula::ArithmeticError::Overflow); + STATIC_REQUIRE(formula::checked_evaluate_si(variance, fixture_a_scaled(scale))->value() + == Rational { Rational::Int { 427 } << 108, 1'953'125 }); } TEST_CASE("a variance that overflows fails with Overflow, naming the determination, in either pass", "[statistics][trace-render]") { - // The squares pass: 9e18 g and -9e18 g have a mean of 0, and the first - // squared deviation, (9e15 kg)^2, leaves int64. - constexpr auto opposite = formula::environment( - formula::measured_series(grams(rat(9'000'000'000'000'000'000)), grams(rat(-9'000'000'000'000'000'000)))); + // The squares pass: 9e27 g and -9e27 g have a mean of 0, and the first + // squared deviation, (9e24 kg)^2, leaves 128 bits. + constexpr Rational::Int ninePer = Rational::Int { 9'000'000'000'000'000'000 } * 1'000'000'000; + constexpr auto opposite = + formula::environment(formula::measured_series(grams(Rational { ninePer }), grams(Rational { -ninePer }))); constexpr auto pair = formula::sample_variance(formula::series); STATIC_REQUIRE(formula::checked_evaluate(pair, opposite).error() == formula::ArithmeticError::Overflow); formula::Trace<> squares {}; (void) formula::checked_evaluate(pair, opposite, formula::RecordingSink<> { squares }); CHECK(formula::render_trace(squares, { .maxSteps = 10 }) - == "1. m = 9000000000000000000 g; -9000000000000000000 g\n" + == "1. m = 9000000000000000000000000000 g; -9000000000000000000000000000 g\n" "2. sample_variance(#1) = overflow in exact arithmetic at element 1\n"); + // 9e18 g and -9e18 g, which overflowed 64 bits: 2 * (9e18)^2 g^2. + constexpr auto opposite64 = formula::environment( + formula::measured_series(grams(rat(9'000'000'000'000'000'000)), grams(rat(-9'000'000'000'000'000'000)))); + STATIC_REQUIRE(formula::checked_evaluate(pair, opposite64)->measurement().value() + == Rational { Rational::Int { 9'000'000'000'000'000'000 } * 9'000'000'000'000'000'000 * 2 }); - // Fixture A scaled by 2^31, where the two-pass form first fails: its mean + // Fixture A scaled by 2^63, where the two-pass form first fails: its mean // still fits, and a squared deviation does not, at the fourth // determination. - constexpr std::int64_t scale = std::int64_t { 1 } << 31; - constexpr auto scaledA = formula::environment(formula::measured_series(grams(rat(402 * scale, 10)), - grams(rat(398 * scale, 10)), - grams(rat(405 * scale, 10)), - grams(rat(440 * scale, 10)), - grams(rat(400 * scale, 10)), - grams(rat(433 * scale, 10)))); + constexpr auto scaledA = fixture_a_scaled(Rational::Int { 1 } << 63); STATIC_REQUIRE(formula::checked_evaluate(variance, scaledA).error() == formula::ArithmeticError::Overflow); STATIC_REQUIRE(formula::checked_evaluate_si(formula::sample_mean(determinations), scaledA).has_value()); formula::Trace<> squaresLate {}; (void) formula::checked_evaluate(variance, scaledA, formula::RecordingSink<> { squaresLate }); CHECK(formula::render_trace(squaresLate, { .maxSteps = 10 }) - == "1. m = 431644213248/5 g; 427349245952/5 g; 86973087744 g; 94489280512 g; 85899345920 g; 464930209792/5 g\n" + == "1. m = 1853897779407809937408/5 g; 1835451035334100385792/5 g; 373546567492618420224 g; " + "405828369621610135552 g; 368934881474191032320 g; 1996860045979058962432/5 g\n" "2. sample_variance(#1) = overflow in exact arithmetic at element 4\n"); + // Scaled by 2^62, the last power of two the two-pass form holds, the + // variance in kg^2 is 427/125 g^2 times 2^124: 427 * 2^118 / 1953125, + // a 127-bit numerator. Converted to the declared g^2 it gains 10^6 and + // overflows from 2^60; 2^59 is the last scale it answers at. + STATIC_REQUIRE(formula::checked_evaluate_si(variance, fixture_a_scaled(Rational::Int { 1 } << 62))->value() + == Rational { Rational::Int { 427 } << 118, 1'953'125 }); + STATIC_REQUIRE(formula::checked_evaluate_si(variance, fixture_a_scaled(Rational::Int { 1 } << 60)).has_value()); + STATIC_REQUIRE(formula::checked_evaluate(variance, fixture_a_scaled(Rational::Int { 1 } << 60)).error() + == formula::ArithmeticError::Overflow); + STATIC_REQUIRE( + formula::checked_evaluate(variance, fixture_a_scaled(Rational::Int { 1 } << 59))->measurement().value() + == Rational { Rational::Int { 427 } << 118, 125 }); + // Scaled by 2^31, which overflowed 64 bits, the variance is fixture A's + // 427/125 g^2 times 2^62. + constexpr std::int64_t scale64 = std::int64_t { 1 } << 31; + constexpr auto scaledA64 = formula::environment(formula::measured_series(grams(rat(402 * scale64, 10)), + grams(rat(398 * scale64, 10)), + grams(rat(405 * scale64, 10)), + grams(rat(440 * scale64, 10)), + grams(rat(400 * scale64, 10)), + grams(rat(433 * scale64, 10)))); + STATIC_REQUIRE(formula::checked_evaluate(variance, scaledA64)->measurement().value() + == Rational { Rational::Int { 427 } << 62, 125 }); // The mean pass: the largest Rational and 1 kg, whose total overflows at // the second determination before any deviation is taken -- the mean // alone fails there too. constexpr auto heavy = formula::environment( - formula::measured_series(formula::Measured { Rational { std::numeric_limits::max() } }, + formula::measured_series(formula::Measured { Rational { std::numeric_limits::max() } }, formula::Measured { rat(1) })); constexpr auto heavyVariance = formula::sample_variance(formula::series); STATIC_REQUIRE(formula::checked_evaluate_si(heavyVariance, heavy).error() == formula::ArithmeticError::Overflow); @@ -484,8 +590,15 @@ TEST_CASE("a variance that overflows fails with Overflow, naming the determinati formula::Trace<> meanPass {}; (void) formula::checked_evaluate_si(heavyVariance, heavy, formula::RecordingSink<> { meanPass }); CHECK(formula::render_trace(meanPass, { .maxSteps = 10 }) - == "1. m_h = 9223372036854775807 kg; 1 kg\n" + == "1. m_h = 170141183460469231731687303715884105727 kg; 1 kg\n" "2. sample_variance(#1) = overflow in exact arithmetic at element 2\n"); + // The largest 64-bit integer and 1 kg, which overflowed 64 bits: a + // variance of (2^63 - 2)^2 / 2 kg^2. + constexpr auto heavy64 = formula::environment( + formula::measured_series(formula::Measured { Rational { std::numeric_limits::max() } }, + formula::Measured { rat(1) })); + constexpr Rational::Int lessTwo = (Rational::Int { 1 } << 63) - 2; + STATIC_REQUIRE(formula::checked_evaluate_si(heavyVariance, heavy64)->value() == Rational { lessTwo * lessTwo, 2 }); } TEST_CASE("dispersion of negative determinations", "[statistics]") @@ -537,7 +650,7 @@ TEST_CASE("the spread is reported exactly: the rounded root of the variance", "[ (void) formula::checked_evaluate(spread, fixtureA, formula::RecordingSink<> { trace }); CHECK(formula::render_trace(trace, { .maxSteps = 20 }) == "1. m = 201/5 g; 199/5 g; 81/2 g; 44 g; 40 g; 433/10 g\n" - "2. sample_variance(#1) = 427/125000000\n" + "2. sample_variance(#1) = 427/125000000 kg^2\n" "3. round(sqrt(#2), to 2 dp of g) = 37/20 g [nearest, ties away from zero]\n"); CHECK(formula::render(spread) == "round(sqrt(sample_variance(m(i))), to 2 dp of g)"); } @@ -617,10 +730,10 @@ TEST_CASE("a range checked against a critical value times a precision limit at t "9. 1/10 g\n" "10. 1/50\n" "11. level = 3237/80 g [bound by #14]\n" - "12. #10 * #11 = 3237/4000000\n" - "13. #9 + #12 = 3637/4000000\n" - "14. r at level #8 (pass 2 of 2) = #13 = 3637/4000000\n" - "15. #5 * #14 = 40007/200000000\n" + "12. #10 * #11 = 3237/4000 g\n" + "13. #9 + #12 = 3637/4000 g\n" + "14. r at level #8 (pass 2 of 2) = #13 = 3637/4000 g\n" + "15. #5 * #14 = 40007/200000 g\n" "16. require #2 <= #15 [satisfied]\n"); } @@ -631,7 +744,7 @@ TEST_CASE("the variance and the range trace, render and document as the mean doe (void) formula::checked_evaluate(variance, fixtureA, formula::RecordingSink<> { trace }); CHECK(formula::render_trace(trace, { .maxSteps = 20 }) == "1. m = 201/5 g; 199/5 g; 81/2 g; 44 g; 40 g; 433/10 g\n" - "2. sample_variance(#1) = 427/125000000\n"); + "2. sample_variance(#1) = 427/125000000 kg^2\n"); formula::Trace<> ranged {}; (void) formula::checked_evaluate(range, fixtureA, formula::RecordingSink<> { ranged }); CHECK(formula::render_trace(ranged, { .maxSteps = 20 }) @@ -761,18 +874,25 @@ TEST_CASE("a statistic of observations is one step over the observations' own st TEST_CASE("a statistic of observations that fails names the observation, as a series' failure would", "[statistics][trace-render]") { - // 1 kg and 2^62 - 1 kg total 2^62 kg; the third, 2^62 + 9 kg, takes the - // total past 2^63 - 1. The position counts observations, as + // 1 kg and 2^126 - 1 kg total 2^126 kg; the third, 2^126 + 9 kg, takes + // the total past 2^127 - 1. The position counts observations, as // FailureSite::InputObservation does -- not elements. - constexpr auto heavyObserved = formula::environment(formula::MeasuredObservations( - rat(1), Rational { 4611686018427387903 }, Rational { 4611686018427387913 })); + constexpr Rational::Int half = Rational::Int { 1 } << 126; + constexpr auto heavyObserved = formula::environment( + formula::MeasuredObservations(rat(1), Rational { half - 1 }, Rational { half + 9 })); constexpr auto mean = formula::sample_mean(formula::observations); STATIC_REQUIRE(formula::checked_evaluate(mean, heavyObserved).error() == formula::ArithmeticError::Overflow); formula::Trace<> trace {}; (void) formula::checked_evaluate(mean, heavyObserved, formula::RecordingSink<> { trace }); CHECK(formula::render_trace(trace, { .maxSteps = 10 }) - == "1. m_h = 1 kg; 4611686018427387903 kg; 4611686018427387913 kg\n" + == "1. m_h = 1 kg; 85070591730234615865843651857942052863 kg; 85070591730234615865843651857942052873 kg\n" "2. sample_mean(#1) = overflow in exact arithmetic at observation 3\n"); + // With 2^62 - 1 kg and 2^62 + 9 kg, which overflowed 64 bits, the mean + // is (2^63 + 9)/3 kg. + constexpr auto heavyObserved64 = formula::environment(formula::MeasuredObservations( + rat(1), Rational { 4611686018427387903 }, Rational { 4611686018427387913 })); + STATIC_REQUIRE(formula::checked_evaluate(mean, heavyObserved64)->measurement().value() + == Rational { (Rational::Int { 1 } << 63) + 9, 3 }); } TEST_CASE("a statistic of observations renders on them, and the page gives their capacity as a bound", diff --git a/test/trace_render_tests.cpp b/test/trace_render_tests.cpp index 785f956e..a6fbc12b 100644 --- a/test/trace_render_tests.cpp +++ b/test/trace_render_tests.cpp @@ -100,13 +100,13 @@ TEST_CASE("a derivation renders one line per step, in order", "[trace-render]") // Steps are numbered from one and name their operands by number. The two // leaves carry the symbol of the unit they were declared in; the squared - // mass and the quotient carry none, because a computed value has no - // declared unit and `kg2` and `kg2/m3` are not units this library writes. + // mass and the quotient have no declared unit and borrow none, so they + // read in the coherent unit, spelt from the base units. CHECK(text == "1. m = 6 kg\n" - "2. #1^2 = 36\n" + "2. #1^2 = 36 kg^2\n" "3. V = 3 m3\n" - "4. #2 / #3 = 12\n"); + "4. #2 / #3 = 12 kg^2/m^3\n"); } TEST_CASE("a value renders in the unit it was entered in, not in coherent SI", "[trace-render]") @@ -136,9 +136,9 @@ TEST_CASE("a value renders in the unit it was entered in, not in coherent SI", " TEST_CASE("a power times a time reads in the coherent unit, not in kilowatt-hours", "[trace-render]") { // The two leaves read in the units they were entered in, kW and h. Their - // product is 3/2 kW * 4 h = 1500 W * 14400 s = 21600000 J, a computed - // value with no declared unit, so it reads in the unlabelled coherent unit - // -- joules -- as every computed step does, and not as 6 kWh. + // product is 3/2 kW * 4 h = 1500 W * 14400 s = 21600000 J, a product of + // two dimensioned values, which borrows neither one's unit: it reads in + // the coherent unit, joules spelt from the base units, and not as 6 kWh. auto const heater = formula::environment(formula::Measured { formula::Rational { 3, 2 } }, formula::Measured { formula::Rational { 4 } }); @@ -149,7 +149,7 @@ TEST_CASE("a power times a time reads in the coherent unit, not in kilowatt-hour CHECK(formula::render_trace(trace, { .maxSteps = 10 }) == "1. P = 3/2 kW\n" "2. t = 4 h\n" - "3. #1 * #2 = 21600000\n"); + "3. #1 * #2 = 21600000 m^2 kg/s^2\n"); } TEST_CASE("a derivation longer than the limit is cut, and says so", "[trace-render]") @@ -528,7 +528,7 @@ TEST_CASE("a derivation renders a Conditional step's then branch", "[trace-rende == "1. f = 60 MPa\n" "2. 473/10 MPa\n" "3. f = 60 MPa\n" - "4. if #1 > #2 then #3 = 60000000\n"); + "4. if #1 > #2 then #3 = 60 MPa\n"); CHECK(text.find("when(") == std::string::npos); CHECK(text.find("[then]") == std::string::npos); } @@ -550,8 +550,8 @@ TEST_CASE("a derivation renders a Conditional step's else branch", "[trace-rende "2. 473/10 MPa\n" "3. f = 40 MPa\n" "4. 2\n" - "5. #3 * #4 = 80000000\n" - "6. if #1 > #2 else #5 = 80000000\n"); + "5. #3 * #4 = 80 MPa\n" + "6. if #1 > #2 else #5 = 80 MPa\n"); CHECK(text.find("[else]") == std::string::npos); } @@ -633,12 +633,12 @@ TEST_CASE("a derivation renders the comparison a conditional actually made", "[t == "1. f = 60 MPa\n" "2. 473/10 MPa\n" "3. f = 60 MPa\n" - "4. if #1 > #2 then #3 = 60000000\n"); + "4. if #1 > #2 then #3 = 60 MPa\n"); CHECK(less == "1. f = 60 MPa\n" "2. 473/10 MPa\n" "3. f = 60 MPa\n" - "4. if #1 < #2 else #3 = 60000000\n"); + "4. if #1 < #2 else #3 = 60 MPa\n"); } TEST_CASE("a derivation spells a comparison the way render() does", "[trace-render]") @@ -1001,9 +1001,12 @@ inline constexpr BandTable<0> NoBands {}; inline constexpr BreakpointTable<0> NoPoints {}; inline constexpr BreakpointTable<1> OnePoint { breakpoint(1474, 200) }; // 737/100 cm -constexpr std::int64_t Huge = std::int64_t { 1 } << 62; +constexpr formula::Rational::Int Huge = formula::Rational::Int { 1 } << 126; +/// `Huge` as a `Rational`, and one less. +inline constexpr formula::Rational HugeValue { Huge }; +inline constexpr formula::Rational HugeLessOne { Huge - 1 }; -/// Keys 0 and 4 mm against values 0 and 2^62 - 1: at 3 mm the exact answer +/// Keys 0 and 4 mm against values 0 and 2^126 - 1: at 3 mm the exact answer /// does not exist inside `Rational`, so the interpolation itself overflows. inline constexpr BreakpointTable<2> UnrepresentableAnswer { breakpoint(0), breakpoint(4) }; @@ -1252,16 +1255,16 @@ TEST_CASE("a derivation renders an interpolation's own overflow differently from // covering the miss and the relay but not this one would render case two // as a euphemism. constexpr auto own = interpolating_lookup( - var, { rat(0), rat(Huge - 1) }); + var, { rat(0), HugeLessOne }); CHECK(derivationOf(own, diameterOf(3)) == "1. d = 3 mm\n" "2. interpolate(#1) = overflow in exact arithmetic" " [the interpolation itself overflowed, not anything below it]\n"); // The same error enumerator, produced below the lookup instead. - constexpr auto overflowingLength = formula::constant(rat(Huge)) * formula::number(rat(Huge)); + constexpr auto overflowingLength = formula::constant(HugeValue) * formula::number(HugeValue); constexpr auto relayed = interpolating_lookup( - overflowingLength, { rat(0), rat(Huge - 1) }); + overflowingLength, { rat(0), HugeLessOne }); std::vector const relayedLines = lines(derivationOf(relayed, formula::environment())); REQUIRE(relayedLines.size() == 4); @@ -1485,15 +1488,15 @@ TEST_CASE("a documented Celsius reading reads in Celsius, and a documented diffe CHECK(derivationOf(formula::documented(var - var, cited), temperatures) == "1. T_1 = 277/10 " + degreesCelsius + "\n" + "2. T_0 = 163/10 " + degreesCelsius + "\n" - + "3. #1 - #2 = 57/5\n" - "4. #3 = 57/5 [Temperature rise, Example Standard 1:2020, 6.3]\n"); + + "3. #1 - #2 = 57/5 K\n" + "4. #3 = 57/5 K [Temperature rise, Example Standard 1:2020, 6.3]\n"); } TEST_CASE("a documented step over a node that recorded no step keeps the coherent SI unit", "[trace-render][citation]") { // With no line below to take a unit from, the documented step says what - // any computed step says -- its value in coherent SI -- rather than - // guessing one. + // a computed step with no unit to borrow says -- its value in the + // coherent SI unit, spelt after it -- rather than guessing one. constexpr auto node = formula::documented(UntracedLength {}, { .title = "Untraced length" }); formula::Trace<> trace {}; formula::RecordingSink<> sink { trace }; @@ -1509,8 +1512,8 @@ TEST_CASE("a documented step over a consumer node that forwards the sink keeps t // claims the node's operands -- the two readings -- and neither of them // is the value it documents. Taking a unit from one would state a 57/5 K // rise as a Celsius reading of it, and a density in litres, which the - // renderer refuses. The documented step says what any computed step - // says instead: its value in coherent SI. + // renderer refuses. The documented step says what a computed step with + // no unit to borrow says instead: its value in the coherent SI unit. constexpr formula::Citation cited { .title = "Consumer formula", .reference = "Example Standard 1:2020", .section = "6.4" }; @@ -1519,7 +1522,7 @@ TEST_CASE("a documented step over a consumer node that forwards the sink keeps t std::string const rise = derivationOf( formula::documented(forwarding::difference(var, var), cited), temperatures); - CHECK(rise.find(" = 57/5 [Consumer formula, Example Standard 1:2020, 6.4]\n") != std::string::npos); + CHECK(rise.find(" = 57/5 K [Consumer formula, Example Standard 1:2020, 6.4]\n") != std::string::npos); constexpr auto density = formula::documented(forwarding::quotient(var, var), cited); auto const specimen = formula::environment(formula::Measured { formula::Rational { 139 } }, @@ -1530,7 +1533,7 @@ TEST_CASE("a documented step over a consumer node that forwards the sink keeps t REQUIRE(trace.steps.size() == 3); CHECK(trace.steps.back().unit == formula::coherent(formula::dim::Mass / formula::dim::Volume)); CHECK(formula::render_trace(trace, { .maxSteps = 10 }) - .find(" = 139/277 [Consumer formula, Example Standard 1:2020, 6.4]\n") + .find(" = 139/277 kg/m^3 [Consumer formula, Example Standard 1:2020, 6.4]\n") != std::string::npos); } @@ -1551,7 +1554,7 @@ TEST_CASE("a documented step over a consumer node with one operand of its dimens std::string const degreesCelsius = "\xc2\xb0" "C"; CHECK(derivationOf(rise, formula::environment(formula::Measured { formula::Rational { 277, 10 } })) == "1. T_1 = 277/10 " + degreesCelsius + "\n" - + "2. #1 = 57/5 [Rise above the reference, Example Standard 1:2020, 6.5]\n"); + + "2. #1 = 57/5 K [Rise above the reference, Example Standard 1:2020, 6.5]\n"); } TEST_CASE("a derivation renders a lookup's own conversion failure as neither a miss nor a relay", @@ -1560,7 +1563,7 @@ TEST_CASE("a derivation renders a lookup's own conversion failure as neither a m // The band IS found, and the correction it selects then does not survive // being converted out of the table's own result unit. Nothing missed and // nothing below failed. - constexpr auto wide = banded_lookup(var, { rat(Huge) }); + constexpr auto wide = banded_lookup(var, { HugeValue }); CHECK(derivationOf(wide, diameterOf(30)) == "1. d = 30 mm\n" "2. lookup(#1) = overflow in exact arithmetic" @@ -1569,7 +1572,7 @@ TEST_CASE("a derivation renders a lookup's own conversion failure as neither a m // The same state on the exact kind, which has no operand and no // interpolation -- so nothing else in this file would notice the clause // going missing entirely. - constexpr auto far = exact_lookup(RenderedShape::Cylinder, { rat(1127, 1000), rat(Huge) }); + constexpr auto far = exact_lookup(RenderedShape::Cylinder, { rat(1127, 1000), HugeValue }); CHECK(derivationOf(far, formula::environment()) == "1. lookup(key Cylinder) = overflow in exact arithmetic" " [this lookup's own unit conversion failed, not anything below it]\n"); @@ -1578,17 +1581,17 @@ TEST_CASE("a derivation renders a lookup's own conversion failure as neither a m // claiming "the interpolation itself overflowed" about an interpolation // that did no arithmetic at all: 0 cm sits exactly on the first row. constexpr auto afterCurve = - interpolating_lookup(var, { rat(Huge), rat(1127, 1000) }); + interpolating_lookup(var, { HugeValue, rat(1127, 1000) }); CHECK(derivationOf(afterCurve, diameterOf(0)) == "1. d = 0 mm\n" "2. interpolate(#1) = overflow in exact arithmetic" " [this lookup's own unit conversion failed, not anything below it]\n"); // And on the other side of the curve, where it is one enumerator away - // from claiming a three-row curve declares no rows: converting 2^62 metres + // from claiming a three-row curve declares no rows: converting 2^126 metres // into centimetres overflows before any row is looked at. constexpr auto beforeCurve = interpolating_lookup( - formula::constant(rat(Huge)), { rat(873, 10), rat(-1139, 10), rat(1217, 10) }); + formula::constant(HugeValue), { rat(873, 10), rat(-1139, 10), rat(1217, 10) }); std::vector const keySide = lines(derivationOf(beforeCurve, formula::environment())); REQUIRE(keySide.size() == 2); CHECK(bracketed(keySide[1]) == "this lookup's own unit conversion failed, not anything below it"); @@ -1721,7 +1724,7 @@ TEST_CASE("a variant step reads as its operand, with the variant and its positio CHECK(methodDerivation(compressiveStrength) == "1. F = 562 kN\n" "2. 19321 mm2\n" - "3. #1 / #2 = 562000000000/19321\n" + "3. #1 / #2 = 562000000000/19321 kg/(m s^2)\n" "4. round(#3, in MPa) = 291/10 MPa [rounded to 1 dp (method default); nearest, ties away from zero]\n" "5. #4 = 291/10 MPa [variant Cube (1st of 3), selected by tag]\n"); } @@ -2175,7 +2178,7 @@ TEST_CASE("a series that failed at an element names that element, counted from o { // Positions are zero-based in the API and one-based in every text the // library writes. The failure is at zero-based 2, so the line says 3. - constexpr std::int64_t tooLarge = std::numeric_limits::max() / 100; + constexpr formula::Rational::Int tooLarge = std::numeric_limits::max() / 100; constexpr auto overflowing = formula::environment(formula::measured_series( formula::Measured { formula::Rational { 1 } }, formula::Measured { formula::Rational { 2 } }, @@ -2207,8 +2210,8 @@ TEST_CASE("an elementwise step names its operands, and a broadcast scalar appear / formula::var, screens, formula::RecordingSink<> { trace }); - // A computed step has no declared unit, as a scalar quotient has none, so - // the fraction is shown in the coherent unit, exactly. + // A quotient of two masses is a pure number and borrows no unit, so the + // fraction is shown bare, exactly. CHECK(formula::render_trace(trace, { .maxSteps = 30 }) == "1. m_r = 130 g; 210 g; 95 g; 340 g; 28 g\n" "2. m_t = 1250 g\n" @@ -2249,7 +2252,7 @@ TEST_CASE("a series scaled by a pure number reads in the series' unit", "[series CHECK(derivation(factor / retained) == "1. 3/2\n" "2. m_r = 137 g; 213 g; 293 g\n" - "3. #1 / #2 = 1500/137; 500/71; 1500/293\n"); + "3. #1 / #2 = 1500/137 1/kg; 500/71 1/kg; 1500/293 1/kg\n"); // Celsius readings doubled are no readings: 593.7 K is not 2 x 23.7 degC. formula::Trace<> doubled {}; @@ -2258,7 +2261,7 @@ TEST_CASE("a series scaled by a pure number reads in the series' unit", "[series series_trace::readings, formula::RecordingSink<> { doubled }); REQUIRE(doubled.steps.size() == 3); - CHECK(formula::render_trace(doubled, { .maxSteps = 30 }).ends_with("3. #1 * #2 = 5937/10; 6289/10; 6221/10\n")); + CHECK(formula::render_trace(doubled, { .maxSteps = 30 }).ends_with("3. #1 * #2 = 5937/10 K; 6289/10 K; 6221/10 K\n")); } TEST_CASE("a series of prices scaled by a pure number reads in euros", "[series][trace]") @@ -2296,7 +2299,7 @@ TEST_CASE("a per-element constant and a negation each record one step with every // and reads in the coherent unit. CHECK(formula::render_trace(trace, { .maxSteps = 30 }) == "1. 1 g; 2 g; 3 g\n" - "2. -#1 = -1/1000; -1/500; -3/1000\n"); + "2. -#1 = -1/1000 kg; -1/500 kg; -3/1000 kg\n"); REQUIRE(trace.steps.size() == 2); CHECK(trace.steps[0].kind == formula::StepKind::SeriesConstant); CHECK(trace.steps[1].kind == formula::StepKind::ElementwiseNegate); @@ -2335,7 +2338,7 @@ TEST_CASE("an elementwise step whose left operand failed says its right one was == "1. m_r = 130 g; 210 g\n" "2. m_r = 130 g; 210 g\n" "3. m_r = 130 g; 210 g\n" - "4. #2 - #3 = 0; 0\n" + "4. #2 - #3 = 0 kg; 0 kg\n" "5. #1 / #4 = division by zero at element 1\n" "6. #5 * (not evaluated) = division by zero at element 1\n"); } @@ -2351,7 +2354,7 @@ TEST_CASE("a binary step names the side that failed, the side never evaluated an == "1. m_s = 137 g\n" "2. m_s = 137 g\n" "3. m_s = 137 g\n" - "4. #2 - #3 = 0\n" + "4. #2 - #3 = 0 g\n" "5. #1 / #4 = division by zero\n" "6. #5 / (not evaluated) = division by zero\n"); @@ -2441,9 +2444,9 @@ TEST_CASE("a sum, a range and a running total of Celsius readings read in the co std::string const readingsLine = "1. T_r = 237/10 " + degreesCelsius + "; 413/10 " + degreesCelsius + "; 379/10 " + degreesCelsius + "\n"; constexpr auto readings = formula::series; - CHECK(derivationOf(formula::sum(readings), series_trace::readings) == readingsLine + "2. sum(#1) = 18447/20\n"); + CHECK(derivationOf(formula::sum(readings), series_trace::readings) == readingsLine + "2. sum(#1) = 18447/20 K\n"); CHECK(derivationOf(formula::sample_range(readings), series_trace::readings) - == readingsLine + "2. sample_range(#1) = 88/5\n"); + == readingsLine + "2. sample_range(#1) = 88/5 K\n"); CHECK(derivationOf(formula::sample_mean(readings), series_trace::readings) == readingsLine + "2. sample_mean(#1) = 343/10 " + degreesCelsius + "\n"); @@ -2453,7 +2456,7 @@ TEST_CASE("a sum, a range and a running total of Celsius readings read in the co series_trace::readings, formula::RecordingSink<> { running }); CHECK(formula::render_trace(running, { .maxSteps = 30 }) - == readingsLine + "2. cumulative(#1, from first) = 5937/20; 6113/10; 18447/20\n"); + == readingsLine + "2. cumulative(#1, from first) = 5937/20 K; 6113/10 K; 18447/20 K\n"); REQUIRE(running.steps.size() == 2); CHECK(running.steps[1].unit.offsetNumerator == 0); } @@ -2469,9 +2472,9 @@ TEST_CASE("a sum, a range and a mean of Fahrenheit readings read as those of Cel std::string const readingsLine = "1. T_f = 50 " + degreesFahrenheit + "; 51 " + degreesFahrenheit + "\n"; constexpr auto readings = formula::series; CHECK(derivationOf(formula::sum(readings), series_trace::fahrenheitReadings) - == readingsLine + "2. sum(#1) = 51017/90\n"); + == readingsLine + "2. sum(#1) = 51017/90 K\n"); CHECK(derivationOf(formula::sample_range(readings), series_trace::fahrenheitReadings) - == readingsLine + "2. sample_range(#1) = 5/9\n"); + == readingsLine + "2. sample_range(#1) = 5/9 K\n"); CHECK(derivationOf(formula::sample_mean(readings), series_trace::fahrenheitReadings) == readingsLine + "2. sample_mean(#1) = 101/2 " + degreesFahrenheit + "\n"); } @@ -2485,12 +2488,12 @@ TEST_CASE("a sum and a mean of a series in a unit with no symbol read in the coh formula::measured_series(formula::Measured { formula::Rational { 137 } }, formula::Measured { formula::Rational { 263 } })); CHECK(derivationOf(formula::sum(formula::series), gaps) - == "1. w = 137; 263\n" - "2. sum(#1) = 2/5\n"); + == "1. w = 137/1000 m; 263/1000 m\n" + "2. sum(#1) = 2/5 m\n"); // Nor does a mean, which would borrow an offset unit: 0.2 m. CHECK(derivationOf(formula::sample_mean(formula::series), gaps) - == "1. w = 137; 263\n" - "2. sample_mean(#1) = 1/5\n"); + == "1. w = 137/1000 m; 263/1000 m\n" + "2. sample_mean(#1) = 1/5 m\n"); } TEST_CASE("a series with nothing measured traces as absence at every element, and never as zero", "[series][trace]") @@ -2524,7 +2527,7 @@ TEST_CASE("a running total that overflowed names its element, counted from one", // that from the last the position (1) is not the number of additions // made (3). From the first the total overflows at zero-based 2, element 3 // in the text; from the last at zero-based 1, element 2. - constexpr std::int64_t halfOfLimitInKg = std::numeric_limits::max() / 2000 + 1; + constexpr formula::Rational::Int halfOfLimitInKg = std::numeric_limits::max() / 2000 + 1; constexpr auto heavy = formula::environment(formula::measured_series( formula::Measured { formula::Rational { 1 } }, formula::Measured { formula::Rational { halfOfLimitInKg } }, @@ -2671,19 +2674,23 @@ struct Determinations: formula::Quantity DeviationSizes { 3, 4, 5, 6, 8 }; -/// A unit no conversion out of can fit: one of it is 2^63 - 1 coherent units. +/// A unit as large as a `Unit` can state: one of it is 2^63 - 1 coherent +/// units. A row of 50 of it converts; a row near 2^127 / 1000 does not. inline constexpr formula::Unit Enormous { .dimension = formula::dim::Scalar, .magnitudeNumerator = 9'223'372'036'854'775'807, .magnitudeDenominator = 1, .symbolText = formula::symbol("E"), .decimals = 0 }; -/// The critical value at @p countExpression, traced. +/// The critical value at @p countExpression, traced. @p atSix is the row +/// for 6. template -[[nodiscard]] formula::Trace<> criticalTraceOf(Count countExpression, Env const& countInputs) +[[nodiscard]] formula::Trace<> criticalTraceOf(Count countExpression, + Env const& countInputs, + formula::Rational atSix = formula::Rational { 50 }) { formula::Trace<> trace {}; formula::RecordingSink<> sink { trace }; @@ -2693,7 +2700,7 @@ template ::max() }); + CHECK(largestCount.ends_with(" [no row for n = 170141183460469231731687303715884105727 (declared: 3, 4, 5, 6, 8)]\n")); // A count that is no number of determinations names no row either, and // the line says why, pointing at the step that holds the value. @@ -2765,9 +2783,14 @@ TEST_CASE("a derivation of a critical value says whose failure it carries", "[tr // The row is found, and converting its value out of the table's unit // overflows: the lookup's own conversion, not a miss. - std::string const overflowed = - formula::render_trace(criticalTraceOf(var, sixSpecimens), { .maxSteps = 10 }); + formula::Rational const tooWide { std::numeric_limits::max() / 1000 }; + std::string const overflowed = formula::render_trace( + criticalTraceOf(var, sixSpecimens, tooWide), { .maxSteps = 10 }); CHECK(overflowed.ends_with(" [this lookup's own unit conversion failed, not anything below it]\n")); + // 50 of them, which overflowed 64 bits, are 50 * (2^63 - 1). + formula::Trace<> const fitted = criticalTraceOf(var, sixSpecimens); + REQUIRE(!fitted.steps.empty()); + CHECK(fitted.steps.back().value == formula::Rational { formula::Rational::Int { 9'223'372'036'854'775'807 } * 50 }); // A table of no sizes misses every count, and says it declares none. std::string const empty = formula::render_trace( @@ -2787,6 +2810,13 @@ TEST_CASE("a critical-value step records the count and whose failure it carries, CHECK(hit.kind == formula::StepKind::SampleSizeLookup); CHECK(hit.lookupFailure == formula::LookupFailure::None); CHECK(hit.lookupKey == 8); + // A hand-built step, as a `Step` is a public aggregate, whose count is + // past 64 bits: the line names the whole count, both of its words. + auto wideCountTrace = hitTrace; + wideCountTrace.steps.back().lookupKeyHigh = 1; + wideCountTrace.steps.back().lookupKey = 0; + CHECK(formula::render_trace(wideCountTrace, { .maxSteps = 10 }) + .ends_with(" [critical value at n = 18446744073709551616]\n")); // The declared sizes live in the trace's side table, keyed by the step's // index, and the step itself carries nothing for them. REQUIRE(hitTrace.sampleSizeRecords.size() == 1); @@ -2996,23 +3026,23 @@ TEST_CASE("a value whose decimal never ends is a fraction unless an approximatio formula::Trace<> const third = tracedValue(density, formula::environment(formula::Measured { formula::Rational { 1 } }, formula::Measured { formula::Rational { 3 } })); - CHECK(renderedIn(third, formula::NumberStyle::fraction()) == "1. m = 1 kg\n2. V = 3 m3\n3. #1 / #2 = 1/3\n"); - CHECK(renderedIn(third, formula::NumberStyle::exact_decimal()) == "1. m = 1 kg\n2. V = 3 m3\n3. #1 / #2 = 1/3\n"); + CHECK(renderedIn(third, formula::NumberStyle::fraction()) == "1. m = 1 kg\n2. V = 3 m3\n3. #1 / #2 = 1/3 kg/m^3\n"); + CHECK(renderedIn(third, formula::NumberStyle::exact_decimal()) == "1. m = 1 kg\n2. V = 3 m3\n3. #1 / #2 = 1/3 kg/m^3\n"); CHECK(renderedIn(third, approximately) == "1. m = 1 kg\n2. V = 3 m3\n3. #1 / #2 = \xe2\x89\x88" - "0.333\n"); + "0.333 kg/m^3\n"); // Padded to the declared decimals of each unit: 3 for kilograms, 4 for // cubic metres. The quotient's unit is one nobody declared -- the // coherent kg/m3, with no symbol -- so it is never padded, and rounded it // uses that unit's 3 places. CHECK(renderedIn(third, formula::NumberStyle::exact_decimal(formula::DecimalPadding::Padded)) - == "1. m = 1.000 kg\n2. V = 3.0000 m3\n3. #1 / #2 = 1/3\n"); + == "1. m = 1.000 kg\n2. V = 3.0000 m3\n3. #1 / #2 = 1/3 kg/m^3\n"); CHECK(renderedIn(third, formula::NumberStyle::approximate_decimal(formula::RoundingMode::HalfEven, formula::DecimalPadding::Padded)) == "1. m = 1.000 kg\n2. V = 3.0000 m3\n3. #1 / #2 = \xe2\x89\x88" - "0.333\n"); + "0.333 kg/m^3\n"); // An unlabelled value that is an exact decimal shows that it is not // padded: 1.5, not 1.500. formula::Trace<> const threeHalves = @@ -3020,7 +3050,7 @@ TEST_CASE("a value whose decimal never ends is a fraction unless an approximatio formula::environment(formula::Measured { formula::Rational { 3 } }, formula::Measured { formula::Rational { 2 } })); CHECK(renderedIn(threeHalves, formula::NumberStyle::exact_decimal(formula::DecimalPadding::Padded)) - == "1. m = 3.000 kg\n2. V = 2.0000 m3\n3. #1 / #2 = 1.5\n"); + == "1. m = 3.000 kg\n2. V = 2.0000 m3\n3. #1 / #2 = 1.5 kg/m^3\n"); } TEST_CASE("a value the style cannot spell in its unit is not shown, and says why", "[trace-render][decimals]") @@ -3066,13 +3096,13 @@ TEST_CASE("a rejection's statistic and limit are shown exact beside the comparis "3. pass mean = \xe2\x89\x88" "42.3 g\n" "4. #2 * #3 = \xe2\x89\x88" - "0.004\n" + "4.2 g\n" "5. pass 1: 3 values, mean \xe2\x89\x88" "42.3 g\n" "6. rejected element 3 of 3 (47 g) in pass 1: abs(x - mean) = 14/3 g > 127/30 g (deviation from mean)\n" "7. 0.1\n" "8. pass mean = 40 g\n" - "9. #7 * #8 = 0.004\n" + "9. #7 * #8 = 4 g\n" "10. pass 2: 2 values, mean 40 g\n" "11. settled: 1 rejected, 2 remain\n"); @@ -3201,7 +3231,7 @@ TEST_CASE("a number typed rather than computed is shown exact, whatever the styl "2. m = \xe2\x89\x88" "0.286 kg\n" "3. #1 * #2 = \xe2\x89\x88" - "0.095\n"); + "0.095 kg\n"); // The library's pi is a rational it states itself, and has no decimal // that ends. @@ -3210,7 +3240,7 @@ TEST_CASE("a number typed rather than computed is shown exact, whatever the styl == "1. pi = 245850922/78256779\n" "2. m = 1 kg\n" "3. #1 * #2 = \xe2\x89\x88" - "3.142\n"); + "3.142 kg\n"); // A table's row, which each of the three table lookups reads: the banded // one's bounds are shown as typed too, as exact decimals. @@ -3262,7 +3292,7 @@ TEST_CASE("a constant an overlay fixed or derived is shown as typed, whatever th == "1. c = 1/3 [fixed by jurisdiction overlay: Example Standard 12:2021 NA, NA.4]\n" "2. m = 2 kg\n" "3. #1 * #2 = \xe2\x89\x88" - "0.667\n" + "0.667 kg\n" "4. round(#3, in kg) = 0.667 kg [rounded to 3 dp (method default); nearest, ties away from zero]\n" "5. #4 = 0.667 kg [variant PlainDensity (1st of 1), selected by tag]\n"); @@ -3277,7 +3307,7 @@ TEST_CASE("a constant an overlay fixed or derived is shown as typed, whatever th "2. c = #1 = 1/3 [derived by jurisdiction overlay: Example Standard 12:2021 NA, NA.4]\n" "3. m = 2 kg\n" "4. #2 * #3 = \xe2\x89\x88" - "0.667\n" + "0.667 kg\n" "5. round(#4, in kg) = 0.667 kg [rounded to 3 dp (method default); nearest, ties away from zero]\n" "6. #5 = 0.667 kg [variant PlainDensity (1st of 1), selected by tag]\n"); } @@ -3306,7 +3336,7 @@ TEST_CASE("a step that passes a typed number on shows it as typed, whatever the "0.286 kg\n" "2. 1/7 kg\n" "3. 1/3 kg\n" - "4. if #1 > #2 then #3 = 1/3\n"); + "4. if #1 > #2 then #3 = 1/3 kg\n"); } TEST_CASE("a curve's typed points and values are shown as typed, and its computed ones rounded", "[trace-render][decimals]") @@ -3379,7 +3409,7 @@ TEST_CASE("a record's scope over a typed number shows it as typed, whatever the "3. m = \xe2\x89\x88" "0.286 kg\n" "4. #2 * #3 = \xe2\x89\x88" - "0.095\n"); + "0.095 kg^2\n"); } TEST_CASE("a step over a consumer's node that forwards the sink is not taken for its typed operand", @@ -3394,7 +3424,7 @@ TEST_CASE("a step over a consumer's node that forwards the sink is not taken for CHECK(renderedIn(tracedValue(rise, formula::environment()), approximately) == "1. 1/3 m\n" "2. #1 = \xe2\x89\x88" - "0.19 [Rise above the reference, Example Standard 1:2020, 6.5]\n"); + "0.19 m [Rise above the reference, Example Standard 1:2020, 6.5]\n"); // The same for a branch that ran: the branch's step is the constant, and // the conditional's value is the node's. @@ -3407,7 +3437,7 @@ TEST_CASE("a step over a consumer's node that forwards the sink is not taken for "2. 1/7 kg\n" "3. 1/3 kg\n" "4. if #1 > #2 then #3 = \xe2\x89\x88" - "0.19\n"); + "0.19 kg\n"); } TEST_CASE("a replaced variant whose formula is a typed number shows it as typed, whatever the style", @@ -3446,11 +3476,11 @@ TEST_CASE("a precision limit's level over a typed number shows it as typed, what "4. 0.02\n" "5. level = 1/3 kg [bound by #8]\n" "6. #4 * #5 = \xe2\x89\x88" - "0.007\n" + "0.007 kg\n" "7. #3 + #6 = \xe2\x89\x88" - "0.107\n" + "0.107 kg\n" "8. r at level #2 (pass 2 of 2) = #7 = \xe2\x89\x88" - "0.107\n"); + "0.107 kg\n"); } TEST_CASE("a value in a unit nobody declared is never padded, a quantity in unit::One included", @@ -3527,9 +3557,9 @@ TEST_CASE("a precision limit whose limit is a typed number shows it as typed, wh "[trace-render][decimals]") { // Pass 2 states its limit expression's value, the constant 1/7 kg, and - // the product computed from it is rounded. Both passes read in the - // coherent unit here: no placeholder names a quantity whose unit the - // level could borrow. + // the product computed from it is rounded. Pass 1 reads in the coherent + // unit, since no placeholder or level expression names a quantity; pass 2 + // borrows its constant's kilograms. Both spell kg. constexpr auto limitOfSeventh = formula::precision_limit(formula::constant(rat(1, 3)), formula::constant(rat(1, 7))) @@ -3538,25 +3568,25 @@ TEST_CASE("a precision limit whose limit is a typed number shows it as typed, wh tracedValue(limitOfSeventh, formula::environment(formula::Measured { rat(2, 7) })); CHECK(renderedIn(trace, approximately) == "1. 1/3 kg\n" - "2. level (pass 1 of 2) = #1 = 1/3\n" + "2. level (pass 1 of 2) = #1 = 1/3 kg\n" "3. 1/7 kg\n" - "4. r at level #2 (pass 2 of 2) = #3 = 1/7\n" + "4. r at level #2 (pass 2 of 2) = #3 = 1/7 kg\n" "5. m = \xe2\x89\x88" "0.286 kg\n" "6. #4 * #5 = \xe2\x89\x88" - "0.041\n"); + "0.041 kg^2\n"); // A hand-built trace whose limit step does not hold the value pass 2 // states is not taken for typed: pass 2's line is spelled as a computed // one is, rounded. formula::Trace<> forgedLimit = trace; forgedLimit.steps[2].value = rat(1, 11); - CHECK(renderedIn(forgedLimit, approximately).find("4. r at level #2 (pass 2 of 2) = #3 = \xe2\x89\x88" "0.143\n") + CHECK(renderedIn(forgedLimit, approximately).find("4. r at level #2 (pass 2 of 2) = #3 = \xe2\x89\x88" "0.143 kg\n") != std::string::npos); // Nor one whose pass 2 states a value its limit step does not hold. formula::Trace<> forgedPass = trace; forgedPass.steps[3].value = rat(1, 11); - CHECK(renderedIn(forgedPass, approximately).find("4. r at level #2 (pass 2 of 2) = #3 = \xe2\x89\x88" "0.091\n") + CHECK(renderedIn(forgedPass, approximately).find("4. r at level #2 (pass 2 of 2) = #3 = \xe2\x89\x88" "0.091 kg\n") != std::string::npos); } diff --git a/test/trace_shown_unit_tests.cpp b/test/trace_shown_unit_tests.cpp new file mode 100644 index 00000000..336053e4 --- /dev/null +++ b/test/trace_shown_unit_tests.cpp @@ -0,0 +1,772 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// A trace step's number is always shown with the unit it is in: borrowed from +// the operand steps where that is safe, the coherent unit's symbol otherwise, +// and nothing only for a dimensionless value. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "forwarding_nodes.hpp" +#include "household_bill.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +namespace unit = formula::unit; +using formula::Rational; +using formula::var; + +struct SampleMass: formula::Quantity +{ +}; +struct TareMass: formula::Quantity +{ +}; +struct Edge: formula::Quantity +{ +}; +struct Breadth: formula::Quantity +{ +}; + +// Grams with no symbol: a scale the number alone cannot name. +inline constexpr formula::Unit UnnamedGram { .dimension = formula::dim::Mass, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1000 }; +struct UnnamedMass: formula::Quantity +{ +}; +struct UnnamedTotal: formula::Quantity +{ +}; + +struct HeavyMass: formula::Quantity +{ +}; +struct StartTemperature: formula::Quantity +{ +}; +struct EndTemperature: formula::Quantity +{ +}; +struct Strength: formula::Quantity +{ +}; + +// Grams read to four decimals: grams still, whatever the precision. +inline constexpr formula::Unit FineGram { .dimension = formula::dim::Mass, + .magnitudeNumerator = 1, + .magnitudeDenominator = 1000, + .symbolText = formula::symbol("g"), + .decimals = 4 }; +struct FineMass: formula::Quantity +{ +}; + +// A dimensionless value whose unit has a symbol. +struct Share: formula::Quantity +{ +}; + +template +formula::Trace<> recorded_trace(Expression const& formulaExpression, Bound const& inputs) +{ + formula::Trace<> recorded {}; + formula::RecordingSink<> recordingSink { recorded }; + (void) formula::checked_evaluate_si(formulaExpression, inputs, recordingSink); + return recorded; +} + +template +std::string trace_text(Expression const& formulaExpression, Bound const& inputs) +{ + return formula::render_trace(recorded_trace(formulaExpression, inputs), { .maxSteps = 20 }); +} + +// The electricity bill's money: a base dimension of its own, so that a price +// times an energy reads in euros, the coherent unit of money. +inline constexpr formula::Unit Euro { .dimension = formula::base_dimension("EUR"), + .symbolText = formula::symbol("EUR"), + .decimals = 2 }; +inline constexpr formula::Unit EuroPerKilowattHour { .dimension = Euro.dimension / formula::dim::Energy, + .magnitudeNumerator = 1, + .magnitudeDenominator = 3'600'000, + .symbolText = formula::symbol("EUR/kWh"), + .decimals = 4 }; +struct OvenPower: formula::Quantity +{ +}; +struct OvenHours: formula::Quantity +{ +}; +struct SolarYield: formula::Quantity +{ +}; +struct SelfUsedEnergy: formula::Quantity +{ +}; +struct GridPrice: formula::Quantity +{ +}; +struct BaseFee: formula::Quantity +{ +}; + +// The particles of a class, a count. +struct ParticleCount: formula::Quantity +{ +}; + +// A consumer's operation over a series of masses: its lowest element and the +// span of the series, each a mass. +struct LowestAndSpan +{ + static constexpr std::string_view name = "lowest and span"; + static constexpr std::array shapes { formula::InputShape::Series }; + static constexpr std::array outputs { "lowest", "span" }; + + static consteval std::optional> output_dimensions( + std::array declared) noexcept + { + return std::array { declared[0], declared[0] }; + } + + template + static constexpr std::expected, formula::ArithmeticError> compute( + std::span readings) noexcept + { + Rep least = readings[0]; + Rep most = readings[0]; + for (Rep const& each: readings) + { + if (each < least) + least = each; + if (most < each) + most = each; + } + std::expected const spread = formula::RepTraits::subtract(most, least); + if (!spread.has_value()) + return std::unexpected { spread.error() }; + return std::array { least, *spread }; + } +}; + +inline constexpr auto lowestAndSpan = + formula::opaque({ .reference = "Example Standard 12" }, formula::series); + +// Three determinations in grams, and a tare. +inline constexpr auto determinations = formula::environment( + formula::measured_series(formula::Measured { Rational { 402, 10 } }, + formula::Measured { Rational { 398, 10 } }, + formula::Measured { Rational { 433, 10 } }), + formula::Measured { Rational { 7 } }); + +// The permitted values, the bands and the rows below, all in the unnamed +// gram: a line that wrote them in that scale would write numbers a thousand +// times those of the kilograms written after them. +inline constexpr formula::BreakpointTable<2> UnnamedPermitted { formula::breakpoint(3), formula::breakpoint(5) }; +inline constexpr formula::BandTable<2> UnnamedBands { formula::band(0, 1, 5, 1), formula::band(5, 1, 8, 1) }; +inline constexpr formula::BreakpointTable<2> UnnamedRows { formula::breakpoint(2), formula::breakpoint(6) }; +inline constexpr formula::BreakpointTable<1> UnnamedOnlyRow { formula::breakpoint(2) }; + +template +std::string unnamed_trace_text(Expression const& formulaExpression, Rational unnamedGrams) +{ + return trace_text(formulaExpression, formula::environment(formula::Measured { unnamedGrams })); +} + +/// The decimal digits at the start of @p spelled, as an exact number, and +/// @p spelled advanced past them; nothing when it does not start with one. +std::optional take_whole(std::string_view& spelled) +{ + if (spelled.empty() || spelled.front() < '0' || spelled.front() > '9') + return std::nullopt; + Rational parsed {}; + while (!spelled.empty() && spelled.front() >= '0' && spelled.front() <= '9') + { + std::expected const shifted = formula::checked_mul(parsed, Rational { 10 }); + if (!shifted) + return std::nullopt; + std::expected const added = + formula::checked_add(*shifted, Rational { spelled.front() - '0' }); + if (!added) + return std::nullopt; + parsed = *added; + spelled.remove_prefix(1); + } + return parsed; +} + +/// A value as a trace writes it in the fraction style: `-a/b unit`, `a`, +/// `a/b`, each with or without a unit after a space. +struct ShownValue +{ + Rational shownNumber; + std::string_view unitText; +}; + +std::optional parse_shown(std::string_view spelled) +{ + bool const negative = spelled.starts_with('-'); + if (negative) + spelled.remove_prefix(1); + std::optional const wholeNumber = take_whole(spelled); + if (!wholeNumber) + return std::nullopt; + Rational parsed = *wholeNumber; + if (spelled.starts_with('/')) + { + spelled.remove_prefix(1); + std::optional const below = take_whole(spelled); + if (!below) + return std::nullopt; + std::expected const divided = formula::checked_div(parsed, *below); + if (!divided) + return std::nullopt; + parsed = *divided; + } + if (negative) + { + std::expected const negated = formula::checked_negate(parsed); + if (!negated) + return std::nullopt; + parsed = *negated; + } + if (spelled.starts_with(' ')) + spelled.remove_prefix(1); + else if (!spelled.empty()) + return std::nullopt; + return ShownValue { parsed, spelled }; +} + +/// For every step of @p recorded that holds a value: the text after the +/// number names the step's own unit, the coherent unit, or -- only for a +/// dimensionless step -- nothing; and the number, read back from that unit +/// into the coherent one, is exactly the value recorded. The unit is taken +/// from the text, not from the rule that chose it, so a value written in one +/// scale and labelled with another fails here. +/// +/// It reads `Step::value` only, as `value_in_declared_unit` shows it. The other +/// numbers a line states -- an opaque call's output rows, a series' elements, a +/// rejection's clauses, a table's bounds and rows -- come from side tables and +/// other fields, and are pinned by their own tests. +void check_each_value_is_in_the_unit_written_after_it(formula::Trace<> const& recorded) +{ + std::size_t checkedSteps = 0; + for (formula::Step const& recordedStep: recorded.steps) + { + if (!recordedStep.value.has_value() || recordedStep.error.has_value()) + continue; + std::string const shownText = + formula::detail::value_in_declared_unit(recordedStep, recordedStep.value, formula::NumberStyle::fraction()); + INFO("step shown as: " << shownText); + std::optional const parsed = parse_shown(shownText); + REQUIRE(parsed.has_value()); + formula::Unit const coherentUnit = formula::coherent(recordedStep.dimension); + std::optional namedUnit; + if (!parsed->unitText.empty() && parsed->unitText == formula::detail::unit_symbol_text(recordedStep.unit)) + namedUnit = recordedStep.unit; + else if (!parsed->unitText.empty() && parsed->unitText == formula::detail::coherent_unit_text(recordedStep.dimension)) + namedUnit = coherentUnit; + else if (parsed->unitText.empty() && recordedStep.dimension == formula::dim::Scalar) + namedUnit = recordedStep.unit; + REQUIRE(namedUnit.has_value()); + std::expected const backInCoherent = + formula::checked_convert(parsed->shownNumber, *namedUnit, coherentUnit); + REQUIRE(backInCoherent.has_value()); + CHECK(*backInCoherent == *recordedStep.value); + ++checkedSteps; + } + CHECK(checkedSteps > 0); +} +} // namespace + +TEST_CASE("a product of two lengths names the coherent unit of an area", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 5 } }, + formula::Measured { Rational { 8 } }); + CHECK(trace_text(var * var, inputs) + == "1. a = 5 mm\n" + "2. b = 8 mm\n" + "3. #1 * #2 = 1/25000 m^2\n"); +} + +TEST_CASE("a value in a unit with no symbol is shown in the coherent unit, with its symbol", "[trace-render][shown-unit]") +{ + // 3 of an unnamed gram is 3/1000 kg: shown bare, the 3 would claim a scale + // nothing on the line names. + auto const inputs = formula::environment(formula::Measured { Rational { 3 } }); + CHECK(trace_text(var * Rational { 2 }, inputs).starts_with("1. m_u = 3/1000 kg\n")); +} + +TEST_CASE("a dimensionless value is still a bare number", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 413, 10 } }, + formula::Measured { Rational { 7 } }); + CHECK(trace_text(var / var, inputs) + == "1. m = 413/10 g\n" + "2. m_t = 7 g\n" + "3. #1 / #2 = 59/10\n"); +} + +TEST_CASE("a conformity row is shown in the unit its value is shown in", "[trace-render][shown-unit][conformity]") +{ + // The check states its limits in grams with no symbol. Its values read in + // kilograms, so its rows do too: 36 of the unnamed gram against a row of + // 30 to 40 of them is 9/250 kg against 3/100 to 1/25 kg. + constexpr formula::Envelope<2> envelope { formula::LimitRow { formula::limit(Rational { 30 }), + formula::limit(Rational { 40 }) }, + formula::LimitRow { formula::limit(Rational { 50 }), formula::unbounded } }; + constexpr auto massCheck = formula::conformity( + formula::series, envelope, formula::Verdict { "reject the specimen" }); + auto const masses = formula::environment(formula::measured_series( + formula::Measured { Rational { 36 } }, formula::Measured { Rational { 45 } })); + formula::Trace<> recorded {}; + (void) formula::check_conformity(massCheck, masses, formula::RecordingSink<> { recorded }); + CHECK(formula::render_trace(recorded, { .maxSteps = 20 }) + == "1. m = 36 g; 45 g\n" + "2. conform(#1) [1 satisfied, 9/250 kg (from 3/100 to 1/25 kg); " + "2 violated, 9/200 kg (at least 1/20 kg): reject the specimen]\n"); +} + +TEST_CASE("a derivation states a value in a unit with no symbol in the coherent unit, with its symbol", + "[trace-render][shown-unit][worksheet]") +{ + // The header and the inputs list say what the step lines say: 3 of the + // unnamed gram is 3/1000 kg, and twice it 3/500 kg. + constexpr auto doubled = formula::calculation(formula::define(var * Rational { 2 })); + auto sheet = formula::worksheet(doubled, formula::environment(formula::Measured { Rational { 3 } })); + auto const explained = formula::explain_worksheet(sheet); + CHECK(formula::render_derivation(explained, { .maxSteps = 20 }) + == "m_ut = m_u * 2 = 3/500 kg\n" + " 1. m_u = 3/1000 kg\n" + " 2. 2\n" + " 3. #1 * #2 = 3/500 kg\n" + "inputs\n" + " m_u = 3/1000 kg\n"); +} + +TEST_CASE("a value scaled by a pure number reads in its own unit", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 413, 10 } }); + // On the right, as the outlier-rejection limit 6 % of the mean is written. + CHECK(trace_text(Rational { 3, 50 } * var, inputs) + == "1. 3/50\n" + "2. m = 413/10 g\n" + "3. #1 * #2 = 1239/500 g\n"); + // On the left. + CHECK(trace_text(var * Rational { 3, 50 }, inputs) + == "1. m = 413/10 g\n" + "2. 3/50\n" + "3. #1 * #2 = 1239/500 g\n"); + // Divided by a pure number. + CHECK(trace_text(var / Rational { 2 }, inputs) + == "1. m = 413/10 g\n" + "2. 2\n" + "3. #1 / #2 = 413/20 g\n"); + // A pure number divided by a mass is no mass: the coherent unit, 1/kg. + CHECK(trace_text(Rational { 2 } / var, inputs) + == "1. 2\n" + "2. m = 413/10 g\n" + "3. #1 / #2 = 20000/413 1/kg\n"); +} + +TEST_CASE("a sum of two values in one unit reads in it, at the finer precision", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 413, 10 } }, + formula::Measured { Rational { 7 } }, + formula::Measured { Rational { 12345, 10000 } }); + CHECK(trace_text(var - var, inputs) + == "1. m = 413/10 g\n" + "2. m_t = 7 g\n" + "3. #1 - #2 = 343/10 g\n"); + // Grams declared at different decimals are grams: the sum is shown in + // grams, and at the finer of the two precisions. + formula::Trace<> const mixedPrecision = recorded_trace(var + var, inputs); + REQUIRE(mixedPrecision.steps.size() == 3); + CHECK(formula::view(mixedPrecision.steps[2].unit.symbolText) == "g"); + CHECK(mixedPrecision.steps[2].unit.decimals == 4); +} + +TEST_CASE("a sum of values in two units reads in the coherent unit", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 413, 10 } }, + formula::Measured { Rational { 1 } }); + CHECK(trace_text(var + var, inputs) + == "1. m = 413/10 g\n" + "2. M = 1 kg\n" + "3. #1 + #2 = 10413/10000 kg\n"); +} + +TEST_CASE("a difference of two Celsius readings is an interval in kelvin, not a reading", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 20 } }, + formula::Measured { Rational { 25 } }); + CHECK(trace_text(var - var, inputs) + == "1. T_1 = 25 \xc2\xb0" "C\n" + "2. T_0 = 20 \xc2\xb0" "C\n" + "3. #1 - #2 = 5 K\n"); +} + +TEST_CASE("a negation and an absolute value keep their operand's unit, but not an offset one", "[trace-render][shown-unit]") +{ + auto const grams = formula::environment(formula::Measured { Rational { 413, 10 } }); + CHECK(trace_text(-var, grams) + == "1. m = 413/10 g\n" + "2. -#1 = -413/10 g\n"); + CHECK(trace_text(formula::abs(-var), grams) + == "1. m = 413/10 g\n" + "2. -#1 = -413/10 g\n" + "3. abs(#2) = 413/10 g\n"); + // -(20 degC) is no reading at -20 degC: the coherent unit. + auto const celsius = formula::environment(formula::Measured { Rational { 20 } }); + CHECK(trace_text(-var, celsius) + == "1. T_0 = 20 \xc2\xb0" "C\n" + "2. -#1 = -5863/20 K\n"); +} + +TEST_CASE("a conditional reads in its chosen branch's unit, offset or not", "[trace-render][shown-unit]") +{ + auto const strengths = formula::environment(formula::Measured { Rational { 60 } }); + CHECK(trace_text(formula::when(var > formula::constant(Rational { 473, 10 }), + var, + formula::constant(Rational { 0 })), + strengths) + == "1. f = 60 MPa\n" + "2. 473/10 MPa\n" + "3. f = 60 MPa\n" + "4. if #1 > #2 then #3 = 60 MPa\n"); + // A branch's value is a point on its scale, so a Celsius branch reads in + // degrees Celsius. + auto const readings = formula::environment(formula::Measured { Rational { 20 } }, + formula::Measured { Rational { 25 } }); + CHECK(trace_text(formula::when(var > var, var, var), + readings) + == "1. T_1 = 25 \xc2\xb0" "C\n" + "2. T_0 = 20 \xc2\xb0" "C\n" + "3. T_1 = 25 \xc2\xb0" "C\n" + "4. if #1 > #2 then #3 = 25 \xc2\xb0" "C\n"); +} + +TEST_CASE("a Celsius reading scaled by a pure number, and its absolute value, read in kelvin", + "[trace-render][shown-unit]") +{ + // Twice 20 degC is twice 293.15 K, no reading at 40 degC; nor is the + // absolute value of a reading shown as one. + auto const celsius = formula::environment(formula::Measured { Rational { 20 } }); + CHECK(trace_text(var * Rational { 2 }, celsius) + == "1. T_0 = 20 \xc2\xb0" "C\n" + "2. 2\n" + "3. #1 * #2 = 5863/10 K\n"); + CHECK(trace_text(formula::abs(var), celsius) + == "1. T_0 = 20 \xc2\xb0" "C\n" + "2. abs(#1) = 5863/20 K\n"); +} + +TEST_CASE("a pure number scaled by a pure number borrows no unit", "[trace-render][shown-unit]") +{ + // Neither of two dimensionless sides says which one's unit the product + // is in: it stays a bare number, whichever side the share is on. + auto const shares = formula::environment(formula::Measured { Rational { 50 } }); + CHECK(trace_text(var * Rational { 3 }, shares) + == "1. s = 50 %\n" + "2. 3\n" + "3. #1 * #2 = 3/2\n"); + CHECK(trace_text(Rational { 3 } * var, shares) + == "1. 3\n" + "2. s = 50 %\n" + "3. #1 * #2 = 3/2\n"); +} + +TEST_CASE("a conditional whose branch records no step of its own reads in the coherent unit", + "[trace-render][shown-unit]") +{ + // The branch is a consumer's node that forwards the sink: the step the + // conditional claims last is the reading under it, 25 degC, not the + // branch's value, 5 K below it. Shown in degrees Celsius, that value + // would read as the 20 degC it is not. + auto const readings = formula::environment(formula::Measured { Rational { 20 } }, + formula::Measured { Rational { 25 } }); + CHECK(trace_text(formula::when(var > var, + forwarding::rise_above(var, Rational { 5 }), + var), + readings) + == "1. T_1 = 25 \xc2\xb0" "C\n" + "2. T_0 = 20 \xc2\xb0" "C\n" + "3. T_1 = 25 \xc2\xb0" "C\n" + "4. if #1 > #2 then #3 = 5863/20 K\n"); +} + +TEST_CASE("a binary step over a node that records no step of its own reads in the coherent unit", + "[trace-render][shown-unit]") +{ + // Two steps are claimed: the first, in grams, is the forwarding node's + // operand, not the node, and the second the bare 2. The product's unit is + // not read off the first. + auto const grams = formula::environment(formula::Measured { Rational { 413, 10 } }); + CHECK(trace_text(forwarding::rise_above(var, Rational { 1, 100 }) * Rational { 2 }, grams) + == "1. m = 413/10 g\n" + "2. 2\n" + "3. #1 * #2 = 313/5000 kg\n"); +} + +TEST_CASE("a binary step over a node on its right that records no step of its own reads in the coherent unit", + "[trace-render][shown-unit]") +{ + // The mirror of the case above: the pure number is the left operand and + // the forwarding node the right. The second step claimed is the + // forwarding node's operand, in grams, not the node: the product's unit + // is not read off it. + auto const grams = formula::environment(formula::Measured { Rational { 413, 10 } }); + CHECK(trace_text(Rational { 2 } * forwarding::rise_above(var, Rational { 1, 100 }), grams) + == "1. 2\n" + "2. m = 413/10 g\n" + "3. #1 * #2 = 313/5000 kg\n"); +} + +TEST_CASE("a derivation's header shows a value in a unit with a symbol without passing through the coherent unit", + "[trace-render][shown-unit][worksheet]") +{ + // 10^35 kWh is 3.6 * 10^41 J, past the 2^127 a Rational holds: a header + // that converted it from kWh to kWh through joules took a detour that can + // only fail. Typed in, it reads as typed. + constexpr Rational::Int tenToSeventeen = 100'000'000'000'000'000; + constexpr Rational::Int tenToThirtyFive = tenToSeventeen * tenToSeventeen * 10; + auto sheet = formula::worksheet(household::bill, household::bill_environment(household::billValues)); + sheet.set(formula::entered(formula::Measured { Rational { tenToThirtyFive } })); + CHECK(formula::render_derivation(formula::explain_worksheet(sheet), { .maxSteps = 20 }) + == "net_draw = 100000000000000000000000000000000000 kWh, entered by hand in place of monthly_load - " + "self_used\n"); +} + +TEST_CASE("a snap in a unit with no symbol states its permitted values in the coherent unit", + "[trace-render][shown-unit][snap]") +{ + // The permitted values are 3 and 5 of the unnamed gram: 3/1000 and + // 1/200 kg, as the value snapped is. + auto const snappedTo = [](Rational unnamedGrams) { + return unnamed_trace_text( + formula::snapped(var), + unnamedGrams); + }; + CHECK(snappedTo(Rational { 4 }) + == "1. m_u = 1/250 kg\n" + "2. snap(#1) = 1/200 kg [3/1000 kg to 1/200 kg; tie, toward higher]\n"); + CHECK(snappedTo(Rational { 7, 2 }).ends_with("2. snap(#1) = 3/1000 kg [3/1000 kg to 1/200 kg; nearer 3/1000 kg]\n")); + CHECK(snappedTo(Rational { 3 }).ends_with("2. snap(#1) = 3/1000 kg [on 3/1000 kg]\n")); + CHECK(snappedTo(Rational { 6 }).ends_with("[outside the permitted set, 3/1000 kg to 1/200 kg]\n")); +} + +TEST_CASE("a binning's miss in a unit with no symbol states the classes in the coherent unit", + "[trace-render][shown-unit][binning]") +{ + // 9 of the unnamed gram is in no class; the classes cover 0 to under 8 + // of it: 9/1000 kg against 0 to under 1/125 kg. + formula::Trace<> recorded {}; + (void) formula::checked_evaluate_series( + formula::binned(formula::observations), + formula::environment(formula::MeasuredObservations(Rational { 1 }, Rational { 9 }, Rational { 6 })), + formula::RecordingSink<> { recorded }); + CHECK(formula::render_trace(recorded, { .maxSteps = 20 }) + .ends_with("at observation 2 [9/1000 kg in no class; the classes cover 0 to under 1/125 kg]\n")); +} + +TEST_CASE("a lookup keyed in a unit with no symbol states its bands and rows in the coherent unit", + "[trace-render][shown-unit][lookup]") +{ + constexpr auto banded = formula::banded_lookup( + var, { Rational { 10 }, Rational { 20 } }); + CHECK(unnamed_trace_text(banded, Rational { 3 }) + == "1. m_u = 3/1000 kg\n" + "2. lookup(#1) = 10 % [0 to under 1/200 kg]\n"); + CHECK(unnamed_trace_text(banded, Rational { 9 }).ends_with("[in no band; the bands cover 0 to under 1/125 kg]\n")); + + constexpr auto interpolated = formula::interpolating_lookup( + var, { Rational { 10 }, Rational { 30 } }); + CHECK(unnamed_trace_text(interpolated, Rational { 3 }) + == "1. m_u = 3/1000 kg\n" + "2. interpolate(#1) = 15 % [between 1/500 and 3/500 kg]\n"); + CHECK(unnamed_trace_text(interpolated, Rational { 2 }).ends_with("[on the row at 1/500 kg]\n")); + CHECK(unnamed_trace_text(interpolated, Rational { 9 }).ends_with("[outside the curve, which runs 1/500 to 3/500 kg]\n")); + + constexpr auto oneRow = + formula::interpolating_lookup(var, { Rational { 10 } }); + CHECK(unnamed_trace_text(oneRow, Rational { 3 }).ends_with("[outside the curve, whose only row is at 1/500 kg]\n")); +} + +TEST_CASE("two different rows that cannot be shown are not read as one row", "[trace-render][shown-unit][lookup]") +{ + // A hand-built trace, as a `Trace` is a public aggregate: rows declared + // over a zero denominator, 1/0 and 2/0, in a unit with no symbol. No table + // compiles with such a row, and neither names a number, so each reads + // `(not shown: ...)` -- but they are two different rows, and the line must + // not say one. + formula::Trace<> recorded = recorded_trace( + formula::interpolating_lookup(var, + { Rational { 10 }, Rational { 30 } }), + formula::environment(formula::Measured { Rational { 9 } })); + REQUIRE(recorded.steps.size() == 2); + REQUIRE(recorded.steps[1].coveredRange.has_value()); + + // A miss: the curve runs from one row to the other. + recorded.steps[1].coveredRange = formula::LookupRange { 1, 0, 2, 0 }; + CHECK(formula::render_trace(recorded, { .maxSteps = 20 }) + .ends_with("[outside the curve, which runs (not shown: division by zero) to " + "(not shown: division by zero) kg]\n")); + + // A value between the two rows. + recorded.steps[1].error.reset(); + recorded.steps[1].lookupFailure = formula::LookupFailure::None; + recorded.steps[1].value = Rational { 1, 5 }; + recorded.steps[1].selectedSegment = + formula::Segment { formula::Breakpoint { .numerator = 1, .denominator = 0 }, + formula::Breakpoint { .numerator = 2, .denominator = 0 } }; + CHECK(formula::render_trace(recorded, { .maxSteps = 20 }) + .ends_with("[between (not shown: division by zero) and (not shown: division by zero) kg]\n")); + + // A well-formed unit with no symbol can still fail to move a bound into + // the coherent unit, through its offset: 1/(2^63 - 1) times a magnitude + // of 1/(2^63 - 25), plus an offset of 1/(2^63 - 165), overflows. + constexpr formula::Unit WideOffsetGram { .dimension = formula::dim::Mass, + .magnitudeNumerator = 1, + .magnitudeDenominator = INT64_MAX - 24, + .offsetNumerator = 1, + .offsetDenominator = INT64_MAX - 164 }; + CHECK(formula::detail::shown_bound_text(1, INT64_MAX, WideOffsetGram, formula::NumberStyle::fraction()) + == "(not shown: overflow in exact arithmetic)"); +} + +TEST_CASE("a curve over a domain in a unit with no symbol states its rows in the coherent unit", + "[trace-render][shown-unit][curve]") +{ + constexpr auto massCurve = + formula::curve(formula::domain, formula::series_constant(Rational { 10 }, Rational { 30 })); + CHECK(unnamed_trace_text(formula::interpolate_at(massCurve, var), Rational { 3 }) + .ends_with("interpolate(#3, at #4) = 15 % [between 1/500 and 3/500 kg]\n")); + CHECK(unnamed_trace_text(formula::interpolate_at(massCurve, var), Rational { 9 }) + .ends_with("[outside the curve, which runs 1/500 to 3/500 kg]\n")); +} + +TEST_CASE("an opaque output that cannot be shown says so, with no unit after it", "[trace-render][shown-unit][opaque]") +{ + formula::Trace<> recorded = recorded_trace(formula::opaque_output<"span">(lowestAndSpan), determinations); + REQUIRE(recorded.opaqueSteps.size() == 1); + REQUIRE(recorded.opaqueSteps[0].outputs.size() == 2); + // A row built by hand, as a `Trace` is a public aggregate: the span said + // to be in metres, which no mass converts into. + recorded.opaqueSteps[0].outputs[1].unit = unit::Metre; + CHECK(formula::render_trace(recorded, { .maxSteps = 20 }) + .find("; span = (not shown: argument outside the domain of the operation) [inside not shown]") + != std::string::npos); +} + +TEST_CASE("every value a trace shows is in the unit written after it", "[trace-render][shown-unit]") +{ + auto const inputs = formula::environment(formula::Measured { Rational { 413, 10 } }, + formula::Measured { Rational { 7 } }, + formula::Measured { Rational { 1 } }, + formula::Measured { Rational { 5 } }, + formula::Measured { Rational { 8 } }, + formula::Measured { Rational { 20 } }, + formula::Measured { Rational { 25 } }, + formula::Measured { Rational { 60 } }, + formula::Measured { Rational { 3 } }, + formula::Measured { Rational { 12345, 10000 } }); + // A power, a quotient in the coherent unit, scaling, sums in one unit and + // in two, an offset difference, negations of both kinds, an absolute + // value, conditionals over both kinds of branch, and a unit with no symbol. + check_each_value_is_in_the_unit_written_after_it( + recorded_trace(formula::pow<2>(var) / var + var, inputs)); + check_each_value_is_in_the_unit_written_after_it( + recorded_trace(formula::abs(var - var) * Rational { 3, 50 } + var, inputs)); + check_each_value_is_in_the_unit_written_after_it(recorded_trace( + formula::when(var > var, var - var, + -var), + inputs)); + check_each_value_is_in_the_unit_written_after_it(recorded_trace( + formula::when(var > formula::constant(Rational { 473, 10 }), var / Rational { 2 }, + -var), + inputs)); + check_each_value_is_in_the_unit_written_after_it(recorded_trace(var * Rational { 2 } - var, inputs)); +} + +TEST_CASE("every value of a rejection, a bill, the statistics, a precision limit and an opaque call is in the unit written after it", + "[trace-render][shown-unit]") +{ + // The outlier rejection: a 6 % deviation from each pass's mean, over + // 40.2, 39.8, 40.5, 44.0, 40.0 and 43.3 g. + constexpr auto rejection = + formula::without_outliers, formula::KeepAtLeast<4>>( + formula::series, + formula::deviation_from_mean(Rational { 6, 100 } * formula::pass_mean), + formula::Verdict { "discard the determinations and repeat the test" }, + formula::Citation { .title = "Example Standard", .section = "7.4" }); + auto const sample = formula::environment(formula::measured_series( + formula::Measured { Rational { 402, 10 } }, formula::Measured { Rational { 398, 10 } }, + formula::Measured { Rational { 405, 10 } }, formula::Measured { Rational { 44 } }, + formula::Measured { Rational { 40 } }, formula::Measured { Rational { 433, 10 } })); + formula::Trace<> rejected {}; + (void) formula::checked_evaluate_rejection(rejection, sample, formula::RecordingSink<> { rejected }); + CHECK(formula::render_trace(rejected, { .maxSteps = 20 }).find("4. #2 * #3 = 1239/500 g\n") != std::string::npos); + check_each_value_is_in_the_unit_written_after_it(rejected); + + // An electricity bill: kWh - kWh, a power times a time, and a price per + // kWh times an energy, in euros. + auto const bill = formula::environment(formula::Measured { Rational { 5, 2 } }, + formula::Measured { Rational { 30 } }, + formula::Measured { Rational { 150 } }, + formula::Measured { Rational { 120 } }, + formula::Measured { Rational { 8, 25 } }, + formula::Measured { Rational { 25, 2 } }); + check_each_value_is_in_the_unit_written_after_it(recorded_trace(var - var, bill)); + check_each_value_is_in_the_unit_written_after_it(recorded_trace( + (var * var - var * Rational { 1, 2 }) * var + var, bill)); + + // The statistics of three determinations: a mean, a variance and a range. + check_each_value_is_in_the_unit_written_after_it( + recorded_trace(formula::sample_mean(formula::series) - var, determinations)); + check_each_value_is_in_the_unit_written_after_it(recorded_trace( + formula::sample_variance(formula::series) / var + + formula::sample_range(formula::series), + determinations)); + + // A precision limit at the mean of two results, and one over typed + // constants. + auto const pair = formula::environment(formula::Measured { Rational { 40 } }, + formula::Measured { Rational { 40905, 1000 } }, + formula::Measured { Rational { 2, 7 } }); + check_each_value_is_in_the_unit_written_after_it(recorded_trace( + formula::precision_limit( + (var + var) / Rational { 2 }, + formula::constant(Rational { 1, 10 }) + Rational { 1, 50 } * formula::precision_level), + pair)); + check_each_value_is_in_the_unit_written_after_it(recorded_trace( + formula::precision_limit(formula::constant(Rational { 1, 3 }), + formula::constant(Rational { 1, 7 })) + * var, + pair)); + + // An opaque call, its outputs, and a sum over one of them. + check_each_value_is_in_the_unit_written_after_it( + recorded_trace(formula::opaque_output<"span">(lowestAndSpan) + var, determinations)); +} diff --git a/test/trace_tests.cpp b/test/trace_tests.cpp index e0381da5..f58df6af 100644 --- a/test/trace_tests.cpp +++ b/test/trace_tests.cpp @@ -154,9 +154,9 @@ TEST_CASE("a step records the unit its value was declared in", "[trace]") CHECK(trace.steps[1].unit == unit::Millilitre); CHECK(trace.steps[1].value == formula::Rational { 1, 2000 }); - // Anything computed has no declared unit of its own, so the coherent SI - // unit of its dimension is the truthful answer -- not the unit of either - // operand, which a sum of litres and millilitres shows there is no + // A sum of values in two units has no one unit to borrow, so the coherent + // SI unit of its dimension is the truthful answer -- not the unit of + // either operand, which a sum of litres and millilitres shows there is no // defensible way to pick. CHECK(trace.steps[2].kind == formula::StepKind::Add); CHECK(trace.steps[2].unit == formula::coherent(formula::dim::Volume)); @@ -863,19 +863,22 @@ inline constexpr BreakpointTable<3> CurvePoints { var, { rat(873, 10), rat(-1139, 10), rat(1217, 10) }); } -/// `2^62`, an ordinary representable `Rational` used where the scale rather +/// `2^126`, an ordinary representable `Rational` used where the scale rather /// than the arithmetic is the point -- `lookup_tests.cpp`'s own constant, for /// the same purpose. -constexpr std::int64_t Huge = std::int64_t { 1 } << 62; +constexpr formula::Rational::Int Huge = formula::Rational::Int { 1 } << 126; +/// `Huge` as a `Rational`, and one less. +inline constexpr formula::Rational HugeValue { Huge }; +inline constexpr formula::Rational HugeLessOne { Huge - 1 }; -/// Keys 0 and 4 mm against values 0 and 2^62 - 1: probed at 3 mm the exact -/// answer is 3(2^62 - 1)/4, whose reduced numerator is above `Rational`'s +/// Keys 0 and 4 mm against values 0 and 2^126 - 1: probed at 3 mm the exact +/// answer is 3(2^126 - 1)/4, whose reduced numerator is above `Rational`'s /// maximum, so the **interpolation itself** overflows. The same table /// `lookup_tests.cpp` pins the behaviour of. inline constexpr BreakpointTable<2> UnrepresentableAnswer { breakpoint(0), breakpoint(4) }; /// One band wide enough to hit, whose correction is stated in **kilometres**, -/// so that a hit still has an arithmetic step left to fail at: 2^62 km is a +/// so that a hit still has an arithmetic step left to fail at: 2^126 km is a /// perfectly representable `Rational` that does not survive being multiplied /// by 1000 on the way to metres. The band was found, so this is emphatically /// not a miss. @@ -891,7 +894,7 @@ inline constexpr BandTable<2> InnerBands { }; /// An exact table whose corrections are stated in **kilometres**, so that a -/// row that IS found can still fail on the way out: 2^62 km is a perfectly +/// row that IS found can still fail on the way out: 2^126 km is a perfectly /// representable `Rational` that does not survive being multiplied by 1000. /// That is the one failure an exact lookup can have which is not a miss, and /// the exact lookup is a kind where no other own-failure state exists to @@ -900,7 +903,7 @@ inline constexpr KeyTable FarKeys { SpecimenShape::Cube, Speci /// Two rows in centimetres whose values are stated in **kilometres**. 0 cm /// sits exactly on the first row, so the interpolation performs no arithmetic -/// at all and cannot overflow -- and the row's own 2^62 km then does not +/// at all and cannot overflow -- and the row's own 2^126 km then does not /// survive the conversion into metres. The one table that separates "the /// interpolation overflowed" from "the conversion after it did". inline constexpr BreakpointTable<2> FarValues { breakpoint(0), breakpoint(437, 100) }; @@ -1179,7 +1182,7 @@ TEST_CASE("an interpolating lookup step tells its own overflow apart from an ope // computes, so only this kind can overflow of its own accord. Both // derivations below end in a step carrying `Overflow`. constexpr auto own = interpolating_lookup( - var, { rat(0), rat(Huge - 1) }); + var, { rat(0), HugeLessOne }); formula::Trace<> ownOverflow {}; { @@ -1190,11 +1193,11 @@ TEST_CASE("an interpolating lookup step tells its own overflow apart from an ope CHECK(ownOverflow.steps[1].error == formula::ArithmeticError::Overflow); CHECK(ownOverflow.steps[1].lookupFailure == formula::LookupFailure::Computation); - // The same enumerator, produced below the lookup instead: 2^62 mm times - // 2^62 is not representable, and the curve is never consulted. - constexpr auto overflowingLength = formula::constant(rat(Huge)) * formula::number(rat(Huge)); + // The same enumerator, produced below the lookup instead: 2^126 mm times + // 2^126 is not representable, and the curve is never consulted. + constexpr auto overflowingLength = formula::constant(HugeValue) * formula::number(HugeValue); constexpr auto relayed = interpolating_lookup( - overflowingLength, { rat(0), rat(Huge - 1) }); + overflowingLength, { rat(0), HugeLessOne }); formula::Trace<> relayedOverflow {}; { @@ -1291,7 +1294,7 @@ TEST_CASE("an exact lookup that found its row can still fail converting it out", // interpolate, so there is no `Computation` state to mix it up with, and // nothing else here would notice the recorder leaving the field alone. constexpr auto node = - exact_lookup(SpecimenShape::Cylinder, { rat(1127, 1000), rat(Huge) }); + exact_lookup(SpecimenShape::Cylinder, { rat(1127, 1000), HugeValue }); formula::Trace<> trace {}; formula::RecordingSink<> sink { trace }; @@ -1299,7 +1302,7 @@ TEST_CASE("an exact lookup that found its row can still fail converting it out", REQUIRE(trace.steps.size() == 1); CHECK(trace.steps[0].error == formula::ArithmeticError::Overflow); - // The row WAS found: `Cylinder` is row 1 of this table, and 2^62 km is a + // The row WAS found: `Cylinder` is row 1 of this table, and 2^126 km is a // perfectly good `Rational` until it is asked to become metres. CHECK(trace.steps[0].lookupFailure == formula::LookupFailure::Conversion); CHECK(static_cast(trace.steps[0].lookupKey) == 7); @@ -1315,7 +1318,7 @@ TEST_CASE("an interpolating lookup separates its own overflow from the conversio // whole field exists to refuse, one enumerator to the left of where it was // refused. constexpr auto node = - interpolating_lookup(var, { rat(Huge), rat(1127, 1000) }); + interpolating_lookup(var, { HugeValue, rat(1127, 1000) }); formula::Trace<> trace {}; formula::RecordingSink<> sink { trace }; @@ -1333,12 +1336,12 @@ TEST_CASE("an interpolating lookup separates its own overflow from the conversio TEST_CASE("an interpolating lookup whose key conversion failed never consulted its curve", "[trace][lookup]") { - // The third own-failure, on the other side of the curve: converting 2^62 + // The third own-failure, on the other side of the curve: converting 2^126 // metres into the table's centimetres overflows before any row is looked // at. Reporting it as a miss would print "the curve declares no rows" // about a three-row curve. constexpr auto node = interpolating_lookup( - formula::constant(rat(Huge)), { rat(873, 10), rat(-1139, 10), rat(1217, 10) }); + formula::constant(HugeValue), { rat(873, 10), rat(-1139, 10), rat(1217, 10) }); formula::Trace<> trace {}; formula::RecordingSink<> sink { trace }; @@ -1358,9 +1361,9 @@ TEST_CASE("a lookup whose own unit conversion failed is not recorded as a miss", // relayed error. // The result side: 30 mm is comfortably inside [0, 103) mm, so the band IS - // found -- and the correction it selects, 2^62 km, then does not survive + // found -- and the correction it selects, 2^126 km, then does not survive // the conversion into metres. - constexpr auto wide = banded_lookup(var, { rat(Huge) }); + constexpr auto wide = banded_lookup(var, { HugeValue }); formula::Trace<> resultSide {}; { formula::RecordingSink<> sink { resultSide }; @@ -1372,10 +1375,10 @@ TEST_CASE("a lookup whose own unit conversion failed is not recorded as a miss", CHECK(!resultSide.steps[1].selectedBand.has_value()); CHECK(!resultSide.steps[1].coveredRange.has_value()); - // The key side: the operand succeeds, and converting its 2^62 metres into + // The key side: the operand succeeds, and converting its 2^126 metres into // the table's own centimetres overflows before any band is looked at. constexpr auto farTooLong = banded_lookup( - formula::constant(rat(Huge)), { rat(863, 10), rat(1127, 10), rat(1043, 10) }); + formula::constant(HugeValue), { rat(863, 10), rat(1127, 10), rat(1043, 10) }); formula::Trace<> keySide {}; { formula::RecordingSink<> sink { keySide }; @@ -1900,7 +1903,7 @@ TEST_CASE("a series step records every element in coherent SI, and no single val TEST_CASE("a series step that failed records the error and the element, and no elements", "[series][trace]") { using series_recording::Stockpile; - constexpr std::int64_t tooLarge = std::numeric_limits::max() / 100; + constexpr formula::Rational::Int tooLarge = std::numeric_limits::max() / 100; constexpr auto overflowing = formula::environment( formula::measured_series(formula::Measured { formula::Rational { 1 } }, formula::Measured { formula::Rational { 2 } }, @@ -1978,7 +1981,7 @@ TEST_CASE("explain_series keeps a failure and its element, and the step that fai // A series has no throwing spelling, so explain_series carries the // failure in its outcome rather than throwing it away. using series_recording::Stockpile; - constexpr std::int64_t tooLarge = std::numeric_limits::max() / 100; + constexpr formula::Rational::Int tooLarge = std::numeric_limits::max() / 100; constexpr auto overflowing = formula::environment( formula::measured_series(formula::Measured { formula::Rational { 1 } }, formula::Measured { formula::Rational { tooLarge } })); diff --git a/test/transcendental_tests.cpp b/test/transcendental_tests.cpp index 009a2061..1081587b 100644 --- a/test/transcendental_tests.cpp +++ b/test/transcendental_tests.cpp @@ -219,6 +219,7 @@ TEST_CASE("transcendental kernel: every reference value is enclosed and rounds a { constexpr std::array placesTried { -2, -1, 0, 1, 2, 4, 9, 17, 18 }; std::size_t compared = 0; + std::size_t undecided = 0; for (Reference const& row: references) { INFO("row " << row.numerator << "/" << row.denominator); @@ -233,14 +234,32 @@ TEST_CASE("transcendental kernel: every reference value is enclosed and rounds a for (RoundingMode const roundingMode: everyMode) { INFO("places " << places << ", mode " << formula::describe(roundingMode)); - CHECK( - detail::decide_rounding(enclosure->lower, enclosure->upper, DecimalPlaces { places }, roundingMode) - == detail::decide_rounding(referenceEnds[0], referenceEnds[1], DecimalPlaces { places }, roundingMode)); + std::expected const decided = + detail::decide_rounding(enclosure->lower, enclosure->upper, DecimalPlaces { places }, roundingMode); + std::expected const referenceDecided = + detail::decide_rounding(referenceEnds[0], referenceEnds[1], DecimalPlaces { places }, roundingMode); + // Where the kernel's enclosure is too wide to place the value on one side of a boundary + // the tighter reference can, it says Overflow, never a guess. + if (!decided.has_value() && referenceDecided.has_value()) + { + CHECK(decided.error() == formula::ArithmeticError::Overflow); + // Only the exponentials of 43 to 44, at the places whose kept integers 128 bits hold. + CHECK(places >= 17); + CHECK(row.function == Transcendental::Exponential); + CHECK(Rational { 43 } <= Rational { row.numerator, row.denominator }); + CHECK(Rational { row.numerator, row.denominator } <= Rational { 44 }); + ++undecided; + } + else + CHECK(decided == referenceDecided); ++compared; } } // 39 rows, 9 places, 7 modes: a loop over nothing fails here. REQUIRE(compared == 2457); + // Counted: the exponentials of 43, 43.7 and 44 at 17 and 18 places, whose kept integers 128 bits + // hold and whose 37th significant digit the kernel's enclosure cannot settle. + CHECK(undecided == 38); // Three of them written out, so that a reader sees the digits. CHECK(kernel_rounding(Transcendental::NaturalLogarithm, Rational { 2 }, 18, RoundingMode::Floor) == Rational::from_decimal(693'147'180'559'945'309, -18)); diff --git a/test/unit_tests.cpp b/test/unit_tests.cpp index 5cc38c54..9a549468 100644 --- a/test/unit_tests.cpp +++ b/test/unit_tests.cpp @@ -3,6 +3,7 @@ #include +#include #include #include @@ -687,9 +688,16 @@ TEST_CASE("conversion reports failure rather than producing a wrong number", "[u .symbolText = formula::symbol("huge"), .decimals = 0 }; - auto const result = formula::checked_convert(*Rational::make(9223372036854775807LL, 1), absurd, unit::Metre); + auto const result = + formula::checked_convert(Rational { std::numeric_limits::max() }, absurd, unit::Metre); REQUIRE_FALSE(result.has_value()); CHECK(result.error() == ArithmeticError::Overflow); + + // The largest 64-bit integer of them, which overflowed 64 bits, is + // (2^63 - 1)^2 m. + auto const fits = formula::checked_convert(Rational { 9223372036854775807LL }, absurd, unit::Metre); + REQUIRE(fits.has_value()); + CHECK(*fits == Rational { Rational::Int { 9223372036854775807LL } * 9223372036854775807LL }); } TEST_CASE("conversion refuses a zero magnitude rather than converting to zero", "[unit]") diff --git a/test/vocabulary_tests.cpp b/test/vocabulary_tests.cpp index c2aaaba8..e7fec7bb 100644 --- a/test/vocabulary_tests.cpp +++ b/test/vocabulary_tests.cpp @@ -272,12 +272,12 @@ TEST_CASE("the trace names quantities in the sink's vocabulary", "[vocabulary][t CHECK(traceOf(overlaid, everyNamedQuantity) == "1. k_s = 863/1000 [fixed by jurisdiction overlay: Example Standard 12:2021 NA]\n" "2. E = 30 MPa\n" - "3. #1 * #2 = 25890000\n" + "3. #1 * #2 = 2589/100 MPa\n" "4. D = 241 mm\n" "5. lookup(#4) = 1973/1000 [163 to under 331 mm]\n" - "6. #3 * #5 = 51080970\n" + "6. #3 * #5 = 5108097/100000 MPa\n" "7. R = 12 MPa\n" - "8. #6 - #7 = 39080970\n" + "8. #6 - #7 = 3908097/100000 MPa\n" "9. round(#8, in MPa) = 391/10 MPa [rounded to 1 dp (method default); nearest, ties away from zero]\n" "10. #9 = 391/10 MPa [variant Cube (1st of 1), selected by tag]\n"); @@ -287,12 +287,12 @@ TEST_CASE("the trace names quantities in the sink's vocabulary", "[vocabulary][t CHECK(traceOf(overlaid) == "1. k = 863/1000 [fixed by jurisdiction overlay: Example Standard 12:2021 NA]\n" "2. f_c = 30 MPa\n" - "3. #1 * #2 = 25890000\n" + "3. #1 * #2 = 2589/100 MPa\n" "4. d = 241 mm\n" "5. lookup(#4) = 1973/1000 [163 to under 331 mm]\n" - "6. #3 * #5 = 51080970\n" + "6. #3 * #5 = 5108097/100000 MPa\n" "7. E_m = 12 MPa\n" - "8. #6 - #7 = 39080970\n" + "8. #6 - #7 = 3908097/100000 MPa\n" "9. round(#8, in MPa) = 391/10 MPa [rounded to 1 dp (method default); nearest, ties away from zero]\n" "10. #9 = 391/10 MPa [variant Cube (1st of 1), selected by tag]\n"); } @@ -1128,9 +1128,9 @@ TEST_CASE("every node kind traces in the vocabulary", "[vocabulary][trace]") // Every series kind: each series step in the jurisdiction's symbol, the // fixed factor broadcast once, the running total from the last screen, - // and the sum a single value. Computed steps have no declared unit, so - // they read in kilograms, exactly -- except a series scaled by a pure - // number, which reads in its series' grams. + // and the sum a single value. A series or a mean scaled by a pure number + // reads in its series' grams; a variance, and a product of two ranges, + // borrow no unit and read in the coherent kg^2, exactly. // The statistics read the fixed factor through their sample, and each // reads its sample's own step, with every element. The rejection reads // the fixed factor in its sample and again in each pass's limit, and its @@ -1148,7 +1148,7 @@ TEST_CASE("every node kind traces in the vocabulary", "[vocabulary][trace]") "10. m_n = 10 g; 20 g; 40 g\n" "11. x_n = 1487/1000 [fixed by jurisdiction overlay: Example Standard 12:2021 NA]\n" "12. #10 * #11 = 1487/100 g; 1487/50 g; 1487/25 g\n" - "13. sample_variance(#12) = 15478183/30000000000\n" + "13. sample_variance(#12) = 15478183/30000000000 kg^2\n" "14. m_n = 10 g; 20 g; 40 g\n" "15. x_n = 1487/1000 [fixed by jurisdiction overlay: Example Standard 12:2021 NA]\n" "16. #14 * #15 = 1487/100 g; 1487/50 g; 1487/25 g\n" @@ -1157,7 +1157,7 @@ TEST_CASE("every node kind traces in the vocabulary", "[vocabulary][trace]") "19. x_n = 1487/1000 [fixed by jurisdiction overlay: Example Standard 12:2021 NA]\n" "20. #18 * #19 = 1487/100 g; 1487/50 g; 1487/25 g\n" "21. sample_range(#20) = 4461/100 g\n" - "22. #17 * #21 = 19900521/10000000000\n" + "22. #17 * #21 = 19900521/10000000000 kg^2\n" "23. #13 / #22 = 7/27\n" "24. #9 + #23 = 1445227/5454000\n" "25. m_n = 10 g; 20 g; 40 g\n" @@ -1167,14 +1167,14 @@ TEST_CASE("every node kind traces in the vocabulary", "[vocabulary][trace]") "29. 3\n" "30. #28 / #29 = 1487/3000\n" "31. pass mean = 10409/300 g\n" - "32. #30 * #31 = 15478183/900000000\n" + "32. #30 * #31 = 15478183/900000 g\n" "33. pass 1: 3 values, mean 10409/300 g\n" "34. rejected element 3 of 3 (1487/25 g) in pass 1: abs(x - mean) = 1487/60 g > 15478183/900000 g (deviation from mean)\n" "35. x_n = 1487/1000 [fixed by jurisdiction overlay: Example Standard 12:2021 NA]\n" "36. 3\n" "37. #35 / #36 = 1487/3000\n" "38. pass mean = 4461/200 g\n" - "39. #37 * #38 = 2211169/200000000\n" + "39. #37 * #38 = 2211169/200000 g\n" "40. pass 2: 2 values, mean 4461/200 g\n" "41. settled: 1 rejected, 2 remain\n" "42. sample_count(#41) = 2\n" @@ -1185,13 +1185,13 @@ TEST_CASE("every node kind traces in the vocabulary", "[vocabulary][trace]") "47. #46 = 53/2 % [variant EverySample (6th of 6), selected by tag]\n"); CHECK(everyTraceOf() == "1. m_n = 10 g; 20 g; 40 g\n" - "2. -#1 = -1/100; -1/50; -1/25\n" + "2. -#1 = -1/100 kg; -1/50 kg; -1/25 kg\n" "3. m_n = 10 g; 20 g; 40 g\n" "4. 1; 2; 3\n" "5. #3 * #4 = 10 g; 40 g; 120 g\n" "6. x_n = 1487/1000 [fixed by jurisdiction overlay: Example Standard 12:2021 NA]\n" "7. #5 * #6 = 1487/100 g; 1487/25 g; 4461/25 g\n" - "8. #2 + #7 = 487/100000; 987/25000; 3461/25000\n" + "8. #2 + #7 = 487/100000 kg; 987/25000 kg; 3461/25000 kg\n" "9. round(#8, to 0/0/-1 dp of g) = 5 g; 39 g; 140 g [nearest, ties away from zero]\n" "10. cumulative(#9, from last) = 184 g; 179 g; 140 g\n" "11. sum(#10) = 503 g\n" diff --git a/test/wide_rounding_tests.cpp b/test/wide_rounding_tests.cpp index de279f47..123ed2f8 100644 --- a/test/wide_rounding_tests.cpp +++ b/test/wide_rounding_tests.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include namespace @@ -19,6 +20,7 @@ using formula::Rational; using formula::RoundingMode; using W4 = formula::detail::WideUnsigned<4>; using R4 = formula::detail::WideRatio<4>; +using formula::detail::UInt128; using formula::detail::decide_rounding; using formula::detail::narrow_wide_ratio; using formula::detail::round_wide_ratio; @@ -40,12 +42,20 @@ constexpr std::array gridDenominators { { return wide_from_rational<4>(exact); } + +/// The bounds of `Rational::Int`. +constexpr Rational::Int IntMax = std::numeric_limits::max(); +constexpr Rational::Int IntMin = std::numeric_limits::min(); + +/// 2^127, the magnitude of `IntMin`, and one past it. +constexpr W4 twoToOneTwentySeven = W4::from_u128(UInt128 { std::uint64_t { 1 } << 63, 0 }); +constexpr W4 pastIntMin = W4::from_u128(UInt128 { std::uint64_t { 1 } << 63, 1 }); } // namespace TEST_CASE("wide rounding: a wide fraction rounds as checked_round rounds the same Rational", "[wide-rounding]") { // Wherever checked_round answers, round_wide_ratio gives the same, in - // every mode and at every place it accepts. Counted: 43729 cases, those + // every mode and at every place it accepts. Counted: 48833 cases, those // of the grid and the four extremes, at every place from -18 to 18 and in // every mode, where checked_round answers. int checkedCases = 0; @@ -68,23 +78,32 @@ TEST_CASE("wide rounding: a wide fraction rounds as checked_round rounds the sam for (std::int64_t const numeratorValue: gridNumerators) for (std::int64_t const denominatorValue: gridDenominators) judge(Rational { numeratorValue, denominatorValue }); - for (Rational const extreme: { Rational { formula::detail::IntMin }, - Rational { formula::detail::IntMax }, - Rational { formula::detail::IntMin, 3 }, - Rational { formula::detail::IntMax, 2 } }) + for (Rational const extreme: + { Rational { IntMin }, Rational { IntMax }, Rational { IntMin, 3 }, Rational { IntMax, 2 } }) judge(extreme); - CHECK(checkedCases == 43729); + CHECK(checkedCases == 48833); CHECK(agreedCases == checkedCases); } TEST_CASE("wide rounding: a value checked_round refuses is answered when its rounding fits", "[wide-rounding]") { - // 10/3 at 18 dp: checked_round forms 10 * 10^18, past IntMax, and refuses; - // the wide fraction rounds to 3333333333333333333 / 10^18, which fits. - STATIC_REQUIRE(formula::checked_round(Rational { 10, 3 }, DecimalPlaces { 18 }, RoundingMode::HalfEven).error() + // (10^21 + 1)/9 at 18 dp: checked_round forms (10^21 + 1) * 10^18, past + // IntMax, and refuses; the wide fraction, in 256 bits, rounds to + // 111111111111111111111222222222222222222 / 10^18, which fits. + constexpr Rational::Int quintillion = 1'000'000'000'000'000'000; + constexpr Rational unrounded { quintillion * 1000 + 1, 9 }; + STATIC_REQUIRE(formula::checked_round(unrounded, DecimalPlaces { 18 }, RoundingMode::HalfEven).error() == formula::ArithmeticError::Overflow); + constexpr Rational::Int keptDigits = (Rational::Int { 111'111'111'111'111'111 } * 1000 + 111) * quintillion + + 222'222'222'222'222'222; + STATIC_REQUIRE(*round_wide_ratio(wide_from_rational<8>(unrounded), DecimalPlaces { 18 }, RoundingMode::HalfEven) + == Rational { keptDigits, quintillion }); + // 10/3 at 18 dp, which checked_round refused in 64 bits, it answers, as + // the wide fraction does. + STATIC_REQUIRE(formula::checked_round(Rational { 10, 3 }, DecimalPlaces { 18 }, RoundingMode::HalfEven).value() + == Rational { 3'333'333'333'333'333'333ULL, quintillion }); STATIC_REQUIRE(*round_wide_ratio(from(Rational { 10, 3 }), DecimalPlaces { 18 }, RoundingMode::HalfEven) - == Rational { 3'333'333'333'333'333'333, 1'000'000'000'000'000'000 }); + == Rational { 3'333'333'333'333'333'333ULL, quintillion }); } TEST_CASE("wide rounding: ties follow the mode and the sign", "[wide-rounding]") @@ -155,25 +174,35 @@ TEST_CASE("wide rounding: a fraction wider than 64 bits rounds exactly", "[wide- TEST_CASE("wide rounding: the kept integer must fit Rational and the places must be in range", "[wide-rounding]") { - constexpr W4 twoToSixtyThree = W4::from_u64(std::uint64_t { 1 } << 63); - // -2^63 is IntMin; +2^63 is one past IntMax, and -2^63 - 1 one past IntMin: - // Overflow, never IntMax with the sign lost. + // -2^127 is IntMin; +2^127 is one past IntMax, and -2^127 - 1 one past + // IntMin: Overflow, never IntMax with the sign lost. STATIC_REQUIRE( - *round_wide_ratio(R4 { true, twoToSixtyThree, W4::from_u64(1) }, DecimalPlaces { 0 }, RoundingMode::HalfEven) - == Rational { formula::detail::IntMin }); + *round_wide_ratio(R4 { true, twoToOneTwentySeven, W4::from_u64(1) }, DecimalPlaces { 0 }, RoundingMode::HalfEven) + == Rational { IntMin }); STATIC_REQUIRE( - round_wide_ratio(R4 { false, twoToSixtyThree, W4::from_u64(1) }, DecimalPlaces { 0 }, RoundingMode::HalfEven).error() + round_wide_ratio(R4 { false, twoToOneTwentySeven, W4::from_u64(1) }, DecimalPlaces { 0 }, RoundingMode::HalfEven) + .error() == formula::ArithmeticError::Overflow); - constexpr W4 pastIntMin = W4::from_u64((std::uint64_t { 1 } << 63) + 1U); STATIC_REQUIRE( round_wide_ratio(R4 { true, pastIntMin, W4::from_u64(1) }, DecimalPlaces { 0 }, RoundingMode::HalfEven).error() == formula::ArithmeticError::Overflow); + // +2^63, which 64 bits refused, is kept. + STATIC_REQUIRE(*round_wide_ratio(R4 { false, W4::from_u64(std::uint64_t { 1 } << 63), W4::from_u64(1) }, + DecimalPlaces { 0 }, + RoundingMode::HalfEven) + == Rational { std::uint64_t { 1 } << 63 }); + // 2^127 to the nearest 10^18 is 170141183460469231732 * 10^18, past IntMax; + // 2^70, which 64 bits refused, is 1181 * 10^18. + STATIC_REQUIRE(round_wide_ratio(R4 { false, twoToOneTwentySeven, W4::from_u64(1) }, + DecimalPlaces { -18 }, + RoundingMode::HalfEven) + .error() + == formula::ArithmeticError::Overflow); STATIC_REQUIRE( - round_wide_ratio(R4 { false, *formula::detail::shift_left_checked_or_none(W4::from_u64(1), 70), W4::from_u64(1) }, - DecimalPlaces { -18 }, - RoundingMode::HalfEven) - .error() - == formula::ArithmeticError::Overflow); + *round_wide_ratio(R4 { false, *formula::detail::shift_left_checked_or_none(W4::from_u64(1), 70), W4::from_u64(1) }, + DecimalPlaces { -18 }, + RoundingMode::HalfEven) + == Rational { Rational::Int { 1181 } * 1'000'000'000'000'000'000 }); STATIC_REQUIRE(round_wide_ratio(from(Rational { 1, 3 }), DecimalPlaces { 19 }, RoundingMode::HalfEven).error() == formula::ArithmeticError::Overflow); STATIC_REQUIRE(round_wide_ratio(from(Rational { 1, 3 }), DecimalPlaces { -19 }, RoundingMode::HalfEven).error() @@ -189,20 +218,28 @@ TEST_CASE("wide rounding: the kept integer must fit Rational and the places must TEST_CASE("wide rounding: narrow_wide_ratio is the exact value or Overflow", "[wide-rounding]") { STATIC_REQUIRE(*narrow_wide_ratio(R4 { false, W4::from_u64(6), W4::from_u64(4) }) == Rational { 3, 2 }); - STATIC_REQUIRE(*narrow_wide_ratio(R4 { true, W4::from_u64(std::uint64_t { 1 } << 63), W4::from_u64(1) }) - == Rational { formula::detail::IntMin }); - STATIC_REQUIRE(narrow_wide_ratio(R4 { false, W4::from_u64(std::uint64_t { 1 } << 63), W4::from_u64(1) }).error() - == formula::ArithmeticError::Overflow); - // -2^63 - 1 is one past IntMin. A denominator of IntMax fits; of 2^63 it - // does not, nor of 2^64 - 1, which a cast to Rational::Int would read as -1. - STATIC_REQUIRE(narrow_wide_ratio(R4 { true, W4::from_u64((std::uint64_t { 1 } << 63) + 1U), W4::from_u64(1) }).error() + STATIC_REQUIRE(*narrow_wide_ratio(R4 { true, twoToOneTwentySeven, W4::from_u64(1) }) == Rational { IntMin }); + STATIC_REQUIRE(narrow_wide_ratio(R4 { false, twoToOneTwentySeven, W4::from_u64(1) }).error() == formula::ArithmeticError::Overflow); - constexpr W4 intMaxWide = W4::from_u64(static_cast(formula::detail::IntMax)); - STATIC_REQUIRE(*narrow_wide_ratio(R4 { false, W4::from_u64(1), intMaxWide }) == Rational { 1, formula::detail::IntMax }); - STATIC_REQUIRE(narrow_wide_ratio(R4 { false, W4::from_u64(1), W4::from_u64(std::uint64_t { 1 } << 63) }).error() + // -2^127 - 1 is one past IntMin. A denominator of IntMax fits; of 2^127 + // it does not, nor of 2^128 - 1, which a cast to Rational::Int would read + // as -1. + STATIC_REQUIRE(narrow_wide_ratio(R4 { true, pastIntMin, W4::from_u64(1) }).error() == formula::ArithmeticError::Overflow); - STATIC_REQUIRE(narrow_wide_ratio(R4 { false, W4::from_u64(1), W4::from_u64(~std::uint64_t { 0 }) }).error() + constexpr W4 intMaxWide = W4::from_u128(formula::detail::wide_magnitude(IntMax)); + STATIC_REQUIRE(*narrow_wide_ratio(R4 { false, W4::from_u64(1), intMaxWide }) == Rational { 1, IntMax }); + STATIC_REQUIRE(narrow_wide_ratio(R4 { false, W4::from_u64(1), twoToOneTwentySeven }).error() == formula::ArithmeticError::Overflow); + constexpr W4 allOnes = W4::from_u128(UInt128 { ~std::uint64_t { 0 }, ~std::uint64_t { 0 } }); + STATIC_REQUIRE(narrow_wide_ratio(R4 { false, W4::from_u64(1), allOnes }).error() == formula::ArithmeticError::Overflow); + // The 64-bit edges, which 64 bits refused, fit: 2^63, and denominators + // of 2^63 and 2^64 - 1. + STATIC_REQUIRE(*narrow_wide_ratio(R4 { false, W4::from_u64(std::uint64_t { 1 } << 63), W4::from_u64(1) }) + == Rational { std::uint64_t { 1 } << 63 }); + STATIC_REQUIRE(*narrow_wide_ratio(R4 { false, W4::from_u64(1), W4::from_u64(std::uint64_t { 1 } << 63) }) + == Rational { 1, Rational::Int { std::uint64_t { 1 } << 63 } }); + STATIC_REQUIRE(*narrow_wide_ratio(R4 { false, W4::from_u64(1), W4::from_u64(~std::uint64_t { 0 }) }) + == Rational { 1, Rational::Int { ~std::uint64_t { 0 } } }); // 2^64 / (3 * 2^64): reduced first, 1/3. constexpr W4 twoToSixtyFour = W4::from_limbs({ 0U, 0U, 1U, 0U }); STATIC_REQUIRE( @@ -229,7 +266,7 @@ TEST_CASE("wide rounding: rounded_in_unit rounds in its unit as a rounding node == formula::ArithmeticError::DomainError); // Wherever RepRounding::round_in answers, the same result, over - // units whose factor is and is not a power of ten. Counted: 64582 cases. + // units whose factor is and is not a power of ten. Counted: 65450 cases. std::array const units { unit::Gram, unit::Millimetre, unit::MillimetrePerMinute, unit::Minute, unit::Percent }; @@ -253,7 +290,7 @@ TEST_CASE("wide rounding: rounded_in_unit rounds in its unit as a rounding node ++agreedCases; } } - CHECK(checkedCases == 64582); + CHECK(checkedCases == 65450); CHECK(agreedCases == checkedCases); } @@ -335,3 +372,16 @@ TEST_CASE("wide rounding: a Rational scaled to a common denominator keeps its si STATIC_REQUIRE(!formula::detail::scaled_to_denominator(Rational { 0 }, twelve)->negative); STATIC_REQUIRE(formula::detail::scaled_to_denominator(Rational { 0 }, twelve)->magnitude.is_zero()); } + +TEST_CASE("a wide integer holds 128 bits exactly, and says when it holds more", "[wide-int]") +{ + using formula::detail::UInt128; + using formula::detail::WideUnsigned; + constexpr UInt128 widest { ~std::uint64_t { 0 }, ~std::uint64_t { 0 } }; + STATIC_REQUIRE(WideUnsigned<4>::from_u128(widest).to_u128() == std::optional { widest }); + STATIC_REQUIRE(WideUnsigned<8>::from_u128(UInt128 { 0x0123456789abcdef, 0xfedcba9876543210 }).to_u128() + == std::optional { UInt128 { 0x0123456789abcdef, 0xfedcba9876543210 } }); + constexpr auto twoTo128 = formula::detail::shift_left_checked_or_none(WideUnsigned<8>::from_u64(1), std::size_t { 128 }); + STATIC_REQUIRE(twoTo128.has_value()); + STATIC_REQUIRE(twoTo128->to_u128() == std::nullopt); +} diff --git a/tools/census/exact_sizes.py b/tools/census/exact_sizes.py index 76719d5c..b38096c3 100644 --- a/tools/census/exact_sizes.py +++ b/tools/census/exact_sizes.py @@ -10,9 +10,10 @@ # - in the result's declared unit (g2, MPa), the unit checked_evaluate # hands the caller. # -# A value that needs more than 63 bits in SI but fits in the declared unit -# can be delivered by 64-bit storage only if the conversion happens inside -# wider arithmetic, or if the statistic is evaluated in a scaled unit. +# A value that needs more than a signed 128-bit integer's 127 bits in SI but +# fits in the declared unit can be delivered by 128-bit storage only if the +# conversion happens inside wider arithmetic, or if the statistic is +# evaluated in a scaled unit. # # Mirrors test/overflow_census_tests.cpp's Draws (splitmix64, seed 20260926) # and its fixtures exactly. Run: python tools/census/exact_sizes.py @@ -44,10 +45,13 @@ def between(self, low, high, places): return Fraction(low * scale + self.next() % span, scale) +LIMIT = (1 << 127) - 1 + + def fits(value): - """Whether an exact value is a 64-bit Rational: |numerator| and the - denominator at most 2^63 - 1 (the numerator may also be -2^63).""" - return abs(value.numerator) <= (1 << 63) - 1 and value.denominator <= (1 << 63) - 1 + """Whether an exact value is a Rational over formula::Int128: |numerator| + and the denominator at most 2^127 - 1 (the numerator may also be -2^127).""" + return abs(value.numerator) <= LIMIT and value.denominator <= LIMIT def widest(value): @@ -80,6 +84,7 @@ def main(): draws = Draws(20260926) unrepresentable_si = 0 unrepresentable_declared = 0 + widest_si = 0 widest_declared = 0 for _ in range(1000): grams = masses(draws, places) @@ -89,27 +94,29 @@ def main(): unrepresentable_si += 1 if not fits(in_g2): unrepresentable_declared += 1 + widest_si = max(widest_si, widest(in_kg2)) widest_declared = max(widest_declared, widest(in_g2)) - print(f"six masses near 40 g at {places} dp: the exact variance does not fit 64 bits in " - f"{unrepresentable_si} of 1000 in kg2 (SI), in {unrepresentable_declared} of 1000 in g2 " - f"(declared; widest {widest_declared} bits)") + print(f"six masses near 40 g at {places} dp: the exact variance does not fit 128 bits in " + f"{unrepresentable_si} of 1000 in kg2 (SI; widest {widest_si} bits), in {unrepresentable_declared} " + f"of 1000 in g2 (declared; widest {widest_declared} bits)") load = Fraction(89300) pi = Fraction(245850922, 78256779) - too_wide = [] - widest_declared = 0 + unrepresentable_si = 0 unrepresentable_declared = 0 + widest_si = 0 + widest_declared = 0 for millimetres in range(101, 164): in_mpa = 4 * load / (pi * Fraction(millimetres) ** 2) in_pa = in_mpa * 10 ** 6 if not fits(in_pa): - too_wide.append(f"{millimetres} ({in_pa.numerator.bit_length()} bits)") + unrepresentable_si += 1 if not fits(in_mpa): unrepresentable_declared += 1 + widest_si = max(widest_si, widest(in_pa)) widest_declared = max(widest_declared, widest(in_mpa)) - print("4F / (pi * d^2), F = 89.3 kN, d = 101 to 163 mm: in Pa (SI) the exact strength needs 64 bits, more than " - "a signed 64-bit integer's 63, at d = " - + ", ".join(too_wide)) + print(f"4F / (pi * d^2), F = 89.3 kN, d = 101 to 163 mm: in Pa (SI) the exact strength does not fit 128 bits at " + f"{unrepresentable_si} of 63 (widest {widest_si} bits)") print(f"4F / (pi * d^2), F = 89.3 kN, d = 101 to 163 mm: in MPa (declared) it does not fit at " f"{unrepresentable_declared} of 63 (widest {widest_declared} bits)") diff --git a/tools/gallery/main.cpp b/tools/gallery/main.cpp index 9190fdb0..a17f0b6d 100644 --- a/tools/gallery/main.cpp +++ b/tools/gallery/main.cpp @@ -470,15 +470,12 @@ constexpr auto settledEstimate = formula::retry