Skip to content

A unit on every computed trace value, and a 128-bit Rational - #12

Merged
christianparpart merged 36 commits into
masterfrom
feature/int128-and-trace-units
Oct 4, 2026
Merged

christianparpart merged 36 commits into
masterfrom
feature/int128-and-trace-units

Conversation

@christianparpart

Copy link
Copy Markdown
Member

A trace is meant to show how a number was reached, but arithmetic on single values printed bare numbers in coherent SI with no unit: the outlier-rejection limit read #2 * #3 = 1239/500000, a mass in kilograms with nothing to say so. Separately, a 64-bit Rational overflowed on realistic laboratory statistics: the sample variance of masses read to 6 decimal places of a gram overflowed on 423 of 1000 samples, rejection by standard deviations at that resolution on 897, and a cylinder's compressive strength on 17 of 63 diameters. This PR fixes both: every computed value in a trace now shows a unit, and Rational stores its numerator and denominator in a new 128-bit integer, so those cases answer.

Changes

A unit on every computed trace value

  • A dimensioned value whose unit has no symbol is followed by the coherent unit spelled from its base units (kg^2, m/s, kg/(m s^2)); a dimensionless value is still a bare number.
  • A computed step borrows its operands' unit where that is safe, read off the operand steps: scaling by a pure number, a sum or difference of values on one scale under one name (at the finer of their precisions), negation and absolute value; a conditional and a precision limit read in the unit of the step they restate. An offset unit is never borrowed for arithmetic, so the difference of two Celsius readings reads in K. The rejection limit now reads #2 * #3 = 1239/500 g.
  • Table bounds, snap neighbours, binning classes and curve rows declared in a unit without a symbol are written in the unit the value beside them is shown in; two rows are told apart by value, not by their spelled text.
  • A test walks whole traces (the rejection example, an electricity bill, statistics, precision limits, an opaque call, and more) and checks that every printed value, read back from the unit written after it, is exactly the value recorded.
  • The guides, the gallery and the doc comments state the rule.

Rational over a 128-bit integer

  • formula::Int128 (int128.hpp): one two-word layout on every compiler, the compiler's own 128-bit integer computing where it has one (GCC, Clang) and portable constexpr code elsewhere (cl, clang-cl), with 64-bit fast paths. It converts to no built-in integer; to_int64() and to_uint64() say when a value does not fit. std::numeric_limits and std::format support it.
  • Rational::Int is Int128, and a Rational is 32 bytes. Every reader of numerator() and denominator() goes through width-agnostic helpers; fields that stay 64 bits (bands, breakpoints, a unit's scale, the transcendental kernel's arguments) refuse a value that does not fit rather than cut it.
  • The overflow census is measured against 127 bits, and docs/numeric-headroom.md is regenerated and rewritten: the 6 dp variance and the rejection overflow on none of 1000 samples, with at least 62 and 58 bits to spare, and the cylinder answers at every diameter with at least 63.
  • Public changes, all in the CHANGELOG:
    • Step gains lookupKeyHigh, so a sample-size lookup names a count above 2^64 − 1 in full.
    • Rational's converting constructor takes every built-in integer of up to 64 bits exactly, std::uint64_t included.
    • band(Rational, Rational) and breakpoint(Rational) refuse a bound or key that does not fit their 64-bit fields.
    • NumberTextCapacity is 128.

Closes #1
Closes #2

Christian Parpart added 30 commits October 3, 2026 17:49
… a 128-bit integer

Two designs that land together. A trace step computed by arithmetic shows a
unit read off its operand steps where that is safe, and otherwise its
coherent symbol, so no computed value prints bare. Rational moves to a new
formula::Int128, native where the compiler has a 128-bit integer and
constexpr software where it does not, so realistic laboratory statistics no
longer overflow.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
… a 128-bit integer

Eight tasks in two independent lanes that merge at the end: the trace
recorder and renderer first, then formula::Int128, the readers of
Rational made width-agnostic, and the switch with the overflow census
re-measured. The Int128 design is refined to convert to no built-in
integer type, so that no 64-bit narrowing can survive the switch
silently, and to keep one storage layout on every compiler.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…has no unit of its own

A unit with no symbol cannot say what scale its number is on, so a trace
step whose unit has none now writes the coherent unit's spelling after
its number (`1239/500000 kg`, `36 kg^2`, `60000000 kg/(m s^2)`). A
dimensionless value is still a bare number.

A value recorded in a unit that has no symbol but is not the coherent one
is shown converted into the coherent unit, with that unit's spelling,
rather than as a bare number in a scale nothing on the line names.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…heir value is shown in

A conformity check whose unit has no symbol showed each element's value in
the coherent unit but its row as bare numbers in the check's own scale, and
a derivation's header showed a value in such a unit as a bare number. Both
now follow the rule every trace value does: a dimensioned value whose unit
has no symbol is converted into the coherent unit and followed by that
unit's spelling.

The decision is made in one place, `detail::shown_unit_of` and
`detail::shown_unit_text`, which a step's value, a squared deviation, a
conformity row and a derivation's header all use.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
… compiler has one and constexpr everywhere

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
… in, where that is safe

A product with exactly one pure number, or a quotient by one, is shown in
the other operand's unit, so 3/50 of a mean in grams reads in grams.
A sum or a difference of two values shown on one scale under one symbol is
shown in that unit, at the finer of the two declared precisions.
A negation and an absolute value are shown in their operand's unit.
A conditional and a precision limit, whose value restates another step's,
are shown in that step's unit, offset or not, when the step holds exactly
that value.
A unit with an offset is never borrowed for a sum, a difference, a scaling
or a negation: the difference of two Celsius readings is an interval, and
reads in kelvin.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
… mutation fails

A Celsius reading scaled by a pure number, and its absolute value, read in
kelvin; a product of two dimensionless values borrows no unit on either
side; a conditional over a branch that records no step of its own, and a
product over such a node, read in the coherent unit. Which side of a binary
node is scaled by a pure number is now written once, for a single value's
node and an elementwise one alike, and comments the borrowed units made
false are corrected.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…flow contract once

Int128's arithmetic is unchanged. The tests now reach the boundaries:
- 0, +-1, +-2^63, 2^64 and -(2^127 - 1) as operands;
- -2^127 as a divisor, and its remainder by -1;
- a shift right by zero of a negative value;
- the checked product's refusal exactly past 128 bits, on both routes;
- every standard integer type converted in;
- to_double with no bits dropped;
- u128_pow10(0) and signed_from_magnitude.

The header states overflow once, as a built-in signed integer's: a
result that does not fit, -2^127 / -1 and division by zero are
precondition violations. An Int128 format spec it does not take is
refused by a guard of its own, which says that {} is the only one.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…n, and a header's value without the coherent detour

A value declared in a unit with no symbol is shown in the coherent unit,
followed by that unit's spelling. The bounds stated beside it were not: a
snap's permitted values, a binning's classes, a banded lookup's bands, an
interpolating lookup's or a curve's rows were written as raw numbers in the
unnamed scale, a thousand times the kilograms written after the value on the
same line. Each is now spelled in the unit the value is shown in, so every
number on a line is in the unit written after it.

A derivation's header converted its value from the declared unit to the
declared unit through the coherent one. For a unit with a symbol that
conversion does nothing but can overflow: 10^13 kWh, a good number of
kilowatt-hours, read "(not shown: overflow in exact arithmetic)". The value
is now shown as it is held.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…nd keep the minimum's negation inside the contract

The portable checked product's cross-term exit is now pinned by
2^65 * 2^63, which overflows only there. That check runs on the
compilers without a 128-bit integer of their own too.

Division and signed_from_magnitude negate -2^127's magnitude through
the bit pattern instead of through unary minus. The result is the same
bits, and no call inside the contract now negates the minimum.

The file comment names the arithmetic operators that carry the fits
precondition. It says that << loses the bits shifted out, as a built-in
<< does since C++20, and that >> fills with the sign.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…he unit written after it

A trace step's number is now always followed by the unit it is in: one the
step declares, one borrowed from the steps it read where that is safe, or the
coherent unit spelt from the base units. A new test walks whole traces -- the
outlier rejection, an electricity bill in euros, the statistics of a series,
a precision limit, an opaque call, conditionals and synthetic formulas -- and
for every step that holds a value reads the number back from the unit
written after it and checks it is the value recorded.

Step::unit's comment, the renderer's comments and every guide that stated the
old rule -- a computed value shown bare, in the coherent unit -- now state the
new one, and each hand-written trace block reads as the library prints it.
The display guide's section on a value in a unit nobody declared shows one
again: a creep rate, a length over a time, in m/s, never padded and rounded
at its first significant digit. The gallery and the numeric-headroom page are
regenerated.

Two more cases are pinned: an opaque output that cannot be shown reads
"(not shown: ...)" with no unit after it, and a product whose right operand
is a consumer's node that forwards the sink reads in the coherent unit.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…pell the shift example on Int128

The file comment now says the shifts are outside the overflow contract,
rather than "not arithmetic", which read against operator>>'s comment
calling >> arithmetic. The operator<< example is written as
Int128 { 1 } << 127, so it cannot read as a built-in int expression.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
No behaviour change. Every reader of a numerator or a denominator now goes
through helpers that work on 128-bit magnitudes: number spelling, decimal
exponents, wide rounding, the least-squares common denominator, sample-size
lookups and the transcendental kernels. The overflow census tally and the
wide integers carry 128-bit magnitudes too, ready for a wider Rational::Int.

Int128 gains checked addition, subtraction and multiplication, floored
division, a digit count and a power-of-ten product, beside their 64-bit
forms. The places that store a Rational in a 64-bit structural type -- band,
breakpoint, a trace's curve points and quotient units, and a rounded root's
unit scale -- narrow on purpose and refuse a value that does not fit:
band() and breakpoint() through guards that fail to compile in a constant
expression.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…pelled text

A curve's single row, and an interpolation that sat on a row, were recognised
by their two bounds reading the same. A bound in a unit with no symbol is
moved into the coherent unit to be written, and two different bounds whose
coherent forms both overflow both read "(not shown: ...)": the line then
claimed one row where the table has two. The bounds are now compared as the
numbers they are.

The walker's comment also says which numbers it does not read: those a line
builds from side tables are pinned by their own tests.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The creep rate in the display example rounds to more places than the coherent
unit's three, so padding could not show on its line. A bearing plate's area,
from two whole-millimetre edges, now stands beside it: padded, its edges read
100.0 mm and 200.0 mm while the area reads 0.02 m^2, and the example checks
it. The guide shows both lines from the example's output.

Test comments and guide sentences that still described a computed step as
always in the coherent unit now say what each line does, and a few over-long
lines are re-wrapped. The numeric-headroom page is regenerated.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
… integer width

The test that two different rows which cannot be shown are not read as one
reached them through rows whose coherent forms overflow 64 bits. A wider
integer would show both, and the test would no longer test anything. The
rows are now declared over a zero denominator, set by hand on a recorded
lookup step: they name no number at any width, both read "(not shown:
division by zero)", and the line must still say there are two.

The README's paragraph on the units a trace shows is re-wrapped to the
file's width.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
… readers

No behaviour change. LongestNumberText is now the longest text the header
really writes: a fraction of a sign, two 39-digit integers, a slash, a space
and a unit symbol, which is never marked approximate. The 64-bit put_whole,
left with no caller, is gone; common_denominator states the four limbs
from_u128 needs; the sample-size static_assert says how a size is compared;
band() and breakpoint() say what their guards do at run time. The quoted
band-gap diagnostic is recaptured at its new line.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
Rational::Int is now formula::Int128. Computations that overflowed 64 bits
on realistic laboratory data now answer exactly:

- the sample variance of six masses read to the microgram: 0 of 1000
  samples overflow at 6 dp (423 did), the least leaving 62 bits of
  headroom of 127;
- the rejection by 7/4 standard deviations at 6 dp: 0 of 1000 overflow
  (897 did), with 58 bits left;
- a cylinder's strength, 4F / (pi * d^2) at 89.3 kN in MPa: no diameter
  from 101 to 163 mm overflows (17 did), with 63 bits left, and the area 79.

The overflow census measures against 127 bits, and the page's tables are
regenerated from it.

The _r literal, from_decimal and rounding keep their limits: a literal's
mantissa is still 64 bits, and rounding still takes 0 to 18 places.
rounded_sqrt computes in 128 bits, so an integer radicand of about 10^6
now rounds to 16 places. Narrowing into the 64-bit structural types --
a Band's bounds, a Breakpoint's key -- is refused, naming its guard, and
never cut; two negative tests pin that. A 64-bit unsigned value converts
to a Rational exactly, so the two negative tests that pinned its refusal
are gone.

A critical value's miss on a count past 64 bits spells the whole count
(Step::lookupKeyHigh holds its high word) rather than "n = 0". The longest
number text, a 39-digit fraction in a 16-byte unit, is pinned at exactly
the buffer's size.

Tests that pinned an Overflow at the 64-bit bound move it to the 128-bit
bound and pin what the 64-bit case now answers.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
… the variance's 128-bit figures

The fifty-readings test now runs its three rounded checks on the
eight-decimal readings, where the exact line overflows, and keeps the
four-decimal ones as the case where both answer. The variance's and the
least-squares fit's doc comments quote the census's 128-bit figures. The
edge companion of the overflowing ratio runs at 1.3e19, below the square
root of 2^127. Rational's integer constructor takes only types of up to
64 bits, as Int128's does. Stale comments and wrapping fixed.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
… the bounds the docs state

The test that the two-pass sample variance holds past the textbook one-pass
form now computes the one-pass form itself: on fixture A scaled by 2^57 it
overflows, where the square of the masses' sum outgrows 127 bits, while the
two-pass form answers exactly; at 2^56 both agree. The overflow test pins
the two-pass form's last scale, 2^62, at 427 * 2^118 / 1953125 kg^2, and
the declared g^2 conversion's first overflow at 2^60.

The decimal logarithm is pinned at 10^38 and 10^-38, the largest powers of
ten a Rational holds. The rounded logarithm and exponential are pinned to
refuse an argument whose numerator or denominator needs more than 64 bits
with Overflow, and to answer just inside that bound.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The headroom page answers its question for a 128-bit Rational: every
realistic case measured answers with far more than 8 bits to spare, and
only the stress control on a different denominator for every point still
overflows. It states what was chosen and why: 128-bit intermediates alone
could not have helped, because checked_mul reduces before it multiplies,
and the variances and strengths are stored in SI, where they need 64 bits
or more; so the stored integer was widened. Headroom is now 127 minus the
bits used, and the stress controls read 2^126 and 2^127.

The guides and doc comments that stated Rational's range as 64 bits now
state 128: the numbers guide's limits, the statistics, display, dimensions
and expressions guides, the powers of ten a Rational holds, the checked
integer primitives, rational_from_double's limits, and the rounded
exponential's range, which keeps the 64-bit argument bound of its kernel.
Int128's numeric_limits members are documented, so Doxygen builds clean.
The CHANGELOG records Int128 and the 128-bit Rational.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…p of a wide negative argument

A double below 2^53 rounds at 18 places, while 1e21 at 18 places, 1e38 at
1 and 2^-100 at -18 are Overflow. A Rational is 32 bytes, takes an
unsigned 64-bit integer and refuses bool. The rounded exponential of
-2^70 is 0: the rule below -43 comes before the kernel's argument bound.
The 2^63 variance fixture is built with the file's own helper.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
rational_from_double rounds at every place only below 2^53 in magnitude.
The lookup's conversion and interpolation-order examples, and the minimum's
root, are restated at 128-bit scale. The rounded logarithms name the
kernel's 64-bit argument bound, and the rounded exponential the order its
rules apply in. Smaller wording fixes on the headroom page, the numbers
guide and several comments.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…ensus re-measured

Brings formula::Int128 (native where the compiler has a 128-bit integer,
portable constexpr elsewhere), every reader of a Rational's numerator and
denominator moved onto 128-bit helpers, Rational::Int switched to Int128,
the overflow census measured against 127 bits, and the documentation of
the new range.

CHANGELOG.md keeps both changes' entries. docs/numeric-headroom.md takes
the 128-bit side and is regenerated after this merge.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The display example now shows an area and a creep rate in the coherent
unit, so its row on the census page moves from 15/17/17 bits to 20/21/21,
with 106 of 127 bits to spare. The gallery regenerates unchanged.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
A derivation's header that shows a kilowatt-hour value as held was pinned
with 10^13 kWh, whose joules (3.6 * 10^19) overflowed 64 bits but fit 128,
so the test passed without the behaviour it guards. It now uses 10^35 kWh,
which fits while its 3.6 * 10^41 J does not, and fails if the header goes
through the coherent unit again. The changelog entry names the new value.

The snap test of distances taken in the key unit relied on a permitted value
whose SI denominator overflowed 64 bits. It now snaps an opening whose
distance to a neighbour in metres needs a denominator past 2^127, pins that
overflow, and snaps exactly in millimetres.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
A 64-bit bound times a unit's 64-bit magnitude always fits a Rational, so a
bound moved into the coherent unit no longer fails by overflowing its scale.
It fails for a pair with a zero denominator, a unit of zero magnitude, or in
principle an offset; the comparison of two bounds now takes two zero
denominators as its example.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
log10 of 2^70 is Overflow, beyond the kernel's 64-bit words, while log10 of
10^30, a power of ten answered before the kernel is asked, is 30. And
2^53 - 1 converts exactly at 18 decimal places.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
rounded_log10 and the two guides said every argument wider than 64 bits is
Overflow; a power of ten up to 10^38 is answered exactly, and an exponential
below -43 is 0 whatever its width. The list of narrow_to_int64's users now
includes a Unit's magnitude, the interpolation note no longer calls its
reasoned cases measured, and four over-long lines are reflowed.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
…mpiler

A Step<Rational> is 1320 bytes on cl, clang-cl, g++ 14 and clang++ 20,
all 64-bit and unchecked, up from 1296: the optional value it holds
inline grew from 24 bytes to 40 with the 32-byte Rational, and
lookupKeyHigh adds 8. The test pinned the old size, so every libstdc++
build failed its static assertion.

That build also showed g++ 14 at -O3 failing -Warray-bounds in
WideUnsigned::to_u128. Its early return left a tail reading only the
low four limbs, which g++ split out of each width and merged across
widths; the merged copy, typed for 19 limbs, was reported as reading
past a 12-limb value. The code it emitted was correct. to_u128 now reads
every limb before deciding, so there is no such tail, and the warning
stays on.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
Christian Parpart added 6 commits October 4, 2026 02:16
WideUnsigned::to_u128 named its array of the low four limbs `low`, which
a consumer may declare as a global: cl's C4459 turned the shadowing into
an error in every build that includes least_squares.hpp. It is now
`lowLimbs`.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The changelog no longer says a derivation's header used to read
`(not shown: ...)` for 10^35 kWh: no release did. It now lists
std::numeric_limits<formula::Int128>, says that Step::unit holds the
unit a scaled, summed, negated, conditional or precision-limit step
borrowed, and states the band and breakpoint refusal in one entry.

The quantities guide says every built-in integer of up to 64 bits
converts exactly, std::uint64_t among them, and a wider one is refused.
The headroom guide says what a sum scaled by the product of the
denominators does now: the twenty masses' headroom pin fails. The
expressions guide names Rational::Int's minimum rather than IntMin,
which is still the 64-bit one. The lookup-tables guide says that band
and breakpoint refuse a bound or key past 64 bits, and how.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
… fails to move

A sample-size lookup's hit spelled only the low word of the count, so a
hand-built Step whose count is past 2^64 read the wrong number. It now
spells both words, as the miss already did; a test pins 2^64.

The comments on shown_bound_text and block_value_text now say that the
move into the coherent unit also fails for a malformed unit, one whose
magnitude is zero (DomainError) or whose magnitude or offset has a zero
denominator (DivisionByZero). A test pins the offset case: a bound of
1/(2^63 - 1) in a unit of magnitude 1/(2^63 - 25) and offset
1/(2^63 - 165) reads `(not shown: overflow in exact arithmetic)`.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The headroom guide said the census printed the same on cl 19.51 and
gcc 13.3, a GCC no longer supported. Every preset regenerates the page
and compares it, and the census tables passed on cl 19.51, clang-cl 22.1,
g++ 14.2 and clang++ 20.1; the guide names those. The least-squares
section of the opaque-operations guide says its overflow figures hold on
the same four compilers, whose tests pin them.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The fit guide named integers and thirds mixed with sevenths among the data
that never overflows on every compiler, but no test measures either shape.
It now names only readings at one and three decimal places, a different
denominator on every point, and the example's own data, which overflows at
27 points. The changelog lists the absolute value among the steps whose
Step::unit borrows its operand's unit, and two comments are made exact.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
The size pin's comment states that a step's inline optional value grew to
40 bytes; that figure is now a static_assert on every build. The census
comment on the twenty masses says the variance failed to evaluate at 64
bits, and that at 128 bits the headroom check is what fails.

Signed-off-by: Christian Parpart <c.parpart@lastrada.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant