Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ API surface).

## [Unreleased]

### Fixed

- **`Rational` no longer invokes undefined behaviour on `INT64_MIN`.** The whole-integer constructor now canonicalises like the other constructors, and unary negation, `abs`, `reciprocal`, and multiplication cross-cancellation remain defined even if the public `numerator` or `denominator` is manually set to `INT64_MIN`. Unrepresentable magnitude is clamped to `INT64_MAX`, matching the existing saturation policy. `reciprocal` hands the inverted pair to the canonicalising constructor rather than negating a component itself, so the clamp is reported through the usual `error` log instead of applied silently.
- **`checkedMul` no longer reports success for a product canonicalisation then changes.** A reduced product of exactly `INT64_MIN` fits an `int64_t` but is not a representable `Rational` component — `canonicalise` clamps it to `-INT64_MAX` — so `mulWouldOverflow` now reports it. `checkedMul(Rational{-2^62}, Rational{2})` returns `Overflow` where it previously returned a clamped value as a success; `operator*` saturates to the same `-INT64_MAX/1` it produced before, under the overflow log rather than the clamp log.
- **`checkedDiv` no longer absorbs an inexact reciprocal.** A divisor carrying a hand-poisoned `INT64_MIN` component has no representable inverse, so `checkedDiv` reports `Overflow` instead of returning the product of the clamped one as a success. `dividedBy`/`operator/` are unchanged and still saturate.

### Changed

- **`LocalBackend::execute` no longer rescans the pending-completion list on
Expand Down
11 changes: 6 additions & 5 deletions docs/spec/util/quantity_type.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,11 +256,12 @@ a value reads identically everywhere. There is a single formatting path and
in-code references to one are references to `std::formatter<Quantity>`.

`formatRationalDecimal` takes the numerator's magnitude through
`math::detail::absU64`, in unsigned arithmetic. Negating in `int64_t` instead
is undefined for `INT64_MIN`, and that value is reachable here: the
whole-integer `Rational{value, DecimalPlaces{n}}` constructor does not
canonicalise, so the clamp that would otherwise remove the trap value never
runs.
`math::detail::absU64`, in unsigned arithmetic. It negated in `int64_t` until
morph#496, which is undefined for `INT64_MIN`. Since morph#537 every `Rational`
constructor canonicalises — including the whole-integer
`Rational{value, DecimalPlaces{n}}` one — so no *constructed* value carries the
trap value. `numerator` is still a public member, so one can be assigned
directly, and the formatter stays defined for that.

**The decimal form.** `formatRationalDecimal` renders the exact `Rational` as a
fixed decimal at its **runtime `DecimalPlaces`** and then trims trailing zeros
Expand Down
75 changes: 38 additions & 37 deletions docs/spec/util/rational.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ the canonical `(numerator, denominator)` pair and ignores `decimalPlaces`.
|---|---|---|
| `operator+`, `operator-`, `operator*` (plain `Rational` × `Rational`) | `Rational` | `noexcept`, return a bare `Rational` — no error channel. This means *representable* results never fail; it does **not** mean the operation cannot go wrong. Reduced int64 cross-terms exceeding ~2^63 **saturate** at `±INT64_MAX/1` and log at `error`; the result is clamped and inexact, and the return type does not say so (see [Overflow & value-range envelope](#overflow--value-range-envelope)). Reduce-before-multiply (Knuth 4.5.1) to extend safe int64 range. Cross-cancellation before multiplication. |
| `operator/`, `dividedBy` (plain `Rational` ÷ `Rational`) | `expected<Rational, RationalError>` | `DivisionByZero` when divisor's numerator is zero — and **that is the only error it reports.** Implemented by multiplying `*this` by the reciprocal (`den/num`, sign carried onto the numerator), so it **also propagates `max` precision**, and so it **saturates on overflow exactly like `operator*`**: an out-of-envelope quotient clamps to `±INT64_MAX/1`, logs at `error`, and is still returned as a *successful* `expected`. Use `checkedDiv` to have that reported. |
| `operator-` (unary) | `Rational` | Negates numerator. Precision preserved. **Negating `INT64_MIN` overflows.** |
| `reciprocal` | `expected<Rational, RationalError>` | Multiplicative inverse. `DivisionByZero` when the value is zero. **Precision is the operand's own `decimalPlaces`, not `max`** (it is a unary operation with no second operand to widen against). |
| `operator-` (unary) | `Rational` | Negates numerator. Precision preserved. A hand-poisoned `INT64_MIN` numerator **clamps** to `INT64_MAX` rather than overflowing. |
| `reciprocal` | `expected<Rational, RationalError>` | Multiplicative inverse. `DivisionByZero` when the value is zero. **Precision is the operand's own `decimalPlaces`, not `max`** (it is a unary operation with no second operand to widen against). The inverted pair goes through the canonicalising constructor, which carries the sign and clamps a hand-poisoned `INT64_MIN` component; nothing is negated here. |
| `operator+=`, `-=`, `*=` (in-place) | `Rational&` | Mutate `*this`, widen precision to `max`, canonicalise. |

## Overflow & value-range envelope
Expand Down Expand Up @@ -177,37 +177,24 @@ result and `llround` maps `(-2^63 - 0.5, -2^63]` onto it. On x86's 80-bit
a poisoned `INT64_MIN` numerator); where `long double == double` the same
literal rounds past the bound and is rejected by the plain `2^63` check.

**`INT64_MIN` negation hazards.** `INT64_MIN` (`-2^63`) has no positive
counterpart in `int64`, so every place that negates a component is a latent UB
site when that exact value reaches it:

- **unary `operator-`** — `Rational{Numerator{-numerator}, ...}`: negating an
`INT64_MIN` numerator overflows.
- **`from`** — guards **only** `denominator == 0`; it does not screen
`INT64_MIN` components, so a hostile-but-nonzero `(INT64_MIN, …)` pair flows
straight into the canonicalising constructor.
- **`reciprocal`** — negates the numerator in the `numerator < 0` branch;
`INT64_MIN` there overflows.
- **Rendering** (`morph::units::detail::formatRationalDecimal`) — **not one of
these.** It takes the numerator's magnitude through `detail::absU64`, which
negates in unsigned arithmetic. Negating in `int64_t` there is UB that UBSan
catches, and it is reachable: the whole-integer
`Rational{value, DecimalPlaces{n}}` constructor does not canonicalise, so the
clamp never runs on that path, and `numerator` is public.
- **`canonicalise`** — **not one of these either.** It clamps an `INT64_MIN`
numerator to `-INT64_MAX` (with an `error`-level log, `reportClamp`) *before*
any sign flip, and computes the gcd through `detail::absU64`, which negates in
unsigned arithmetic. Since it is the shared sink for every constructor and
operator, a value that reaches it is safe.

The wire codec (`setWire`) also defends independently: it maps an `INT64_MIN`
`num`/`den` to `-INT64_MAX` *before* constructing, so untrusted input never
reaches the trap value at all.

The entry points that do **not** canonicalise are where the hazard remains — the
whole-integer `Rational{value, DecimalPlaces{n}}` constructor retains its
numerator verbatim, and `numerator` is a public member. A UB site reached that
way is a confirmed, not a hypothetical, shape.
**`INT64_MIN` handling.** `INT64_MIN` (`-2^63`) has no positive counterpart
in `int64`, so direct signed negation is undefined. The whole-integer
constructor therefore delegates to the canonicalising constructor and clamps
`INT64_MIN` to `-INT64_MAX`, matching the full constructor and wire path.
Comment on lines +180 to +183

The public `numerator` member can still be assigned `INT64_MIN` manually, so
operations that may observe such a value defend independently: unary negation,
`abs`, `reciprocal`, and multiplication overflow/cross-cancellation use either
an explicit saturating branch or `detail::absU64`, which computes magnitude in
unsigned arithmetic. These operations remain defined even for a manually
poisoned value; where the exact magnitude is unrepresentable they clamp to the
adjacent `INT64_MAX` magnitude and preserve the existing error/saturation
policy.

The wire codec (`setWire`) likewise maps an `INT64_MIN` `num`/`den` to
`-INT64_MAX` before constructing, so untrusted input never reaches a signed
negation trap.


### Checked arithmetic

Expand Down Expand Up @@ -266,7 +253,11 @@ there is no valid answer to inspect.
`checkedMul` checks the *cross-cancelled* factors `operator*` actually
multiplies, not the raw operands: cross-cancelling is what keeps most products
in range, so checking beforehand would reject pairs that multiply perfectly
well (`INT64_MAX/2 * 2/1` reduces to `INT64_MAX/1`).
well (`INT64_MAX/2 * 2/1` reduces to `INT64_MAX/1`). A reduced product of
exactly `INT64_MIN` counts as an overflow even though it fits an `int64_t`:
`canonicalise` clamps such a component to `-INT64_MAX`, so reporting success
would hand back a value canonicalisation has already changed. `(-2^62) * 2` is
the shortest case, and needs no poisoned operand.

`checkedDiv` is the division member of the family, and it exists because
division was the one operation with no exact-or-nothing form: `dividedBy`
Expand All @@ -275,7 +266,17 @@ checks *its* result is told a clamped quotient succeeded. `checkedDiv` is
`checkedMul` against `rhs.reciprocal()` — the same operand pair `dividedBy`
forms internally — and it folds both failure modes into the one channel:
`DivisionByZero` propagated from `reciprocal`, `Overflow` from `checkedMul`.
`dividedBy` itself saturates: `Quantity` already folds a
It also reports `Overflow` for a divisor carrying a hand-poisoned `INT64_MIN`
component, *before* forming the reciprocal: that component's `2^63` magnitude
has no `int64` counterpart, so `reciprocal` returns a clamped value that is not
the divisor's inverse, and the product of it would be an inexact success. This
is conservative rather than exact — `2 / (INT64_MIN/1)` has the representable
exact quotient `-1/2^62` — and deliberately so: reaching it would require
carrying the unsigned magnitude through the cross-cancellation rather than going
through `reciprocal`, which would let `checkedDiv` accept quotients `dividedBy`
still saturates, breaking the one-set-of-predicates property below. No value the
type can construct is affected.
`dividedBy` itself is unchanged and still saturates: `Quantity` already folds a
failed division to `nullopt` (`docs/spec/error_handling.md`), and making `/` the
sole operation that refuses to saturate would impose "overflow is fatal" on
every caller, in-tree and out.
Expand Down Expand Up @@ -491,7 +492,7 @@ through `setWire`.
| `checkedAdd(a, b)` | `constexpr expected<Rational, RationalError> noexcept` — exact sum, or `Overflow`. |
| `checkedSub(a, b)` | `constexpr expected<Rational, RationalError> noexcept` — exact difference, or `Overflow`. |
| `checkedMul(a, b)` | `constexpr expected<Rational, RationalError> noexcept` — exact product, or `Overflow`. |
| `checkedDiv(a, b)` | `constexpr expected<Rational, RationalError> noexcept` — exact quotient, or `DivisionByZero`, or `Overflow`. `checkedMul` against `b.reciprocal()`; the form `dividedBy`/`operator/` do not provide, since those saturate and report success. |
| `checkedDiv(a, b)` | `constexpr expected<Rational, RationalError> noexcept` — exact quotient, or `DivisionByZero`, or `Overflow` (including when `b` carries a poisoned `INT64_MIN` component, whose reciprocal is not representable). `checkedMul` against `b.reciprocal()`; the form `dividedBy`/`operator/` do not provide, since those saturate and report success. |
| `setWire(Wire)` | `void noexcept` — rebuilds through the canonicalising constructor, clamping what it cannot represent and counting the clamp. |
| `Wire::validate()` | `constexpr bool noexcept` — whether these raw values decode without being clamped. |
| `WireClampScope` | Scoped observer: how many `Rational` values were clamped while decoding. |
Expand Down Expand Up @@ -535,7 +536,7 @@ expected<Rational, RationalError> operator+(Left const&, Right const&) noexcept;
| Rounding mode is a parameter, defaulting to half away from zero | **`RoundingMode{HalfAwayFromZero, HalfEven}`, `HalfAwayFromZero` default** | A mode had to become visible once rounding became a *storage* operation rather than an implementation detail of display. The default follows morph's own formatter rather than the standards' `HALF_EVEN`, because the point of rounding on the dispatch path is that the stored value equals the displayed one; a `HalfEven` default would break that for every tie until the formatter learned the same mode. |
| No `checkedRound` | **`roundToDecimalPlaces` saturates and logs** | Consistent with `+`/`-`/`*`/`/`: the `checked*` family covers the operations a caller is likely to drive with unbounded inputs. Rounding a value already representable at the target scale — the overwhelmingly common case, and every integer — takes a fast path that cannot overflow at all. |
| 128-bit cross-product comparison | **`detail::mulU64`** | Exact ordering over the full int64 range without overflow. Uses `unsigned __int128` when available (GCC/Clang), portable 32-bit limb decomposition on MSVC. |
| Negation limitation | **`INT64_MIN` overflows** | Documented limitation. The wire codec clamps `INT64_MIN` components away for untrusted input. |
| `INT64_MIN` is not a component | **Clamped to `-INT64_MAX`, with an `error` log** | `-INT64_MIN` is not representable, so canonicalising it is undefined. Every constructor canonicalises, so no constructed value carries it; the members are public, so the operations that could still observe one clamp instead of negating. The wire codec clamps it independently for untrusted input. |
| `fromFloat` not `constexpr` | **Uses `std::llround` / `std::isfinite`** | These standard library functions are not `constexpr`. The `fromFloat` overloads are `inline` out-of-class, `noexcept` but not `constexpr`. |

## Payload shape tag
Expand Down
9 changes: 5 additions & 4 deletions include/morph/util/quantity.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,11 @@ namespace detail {
// invariant guarantees denominator > 0, so only the numerator carries sign.
bool const negative = value.numerator < 0;
// Negate in unsigned arithmetic: `-INT64_MIN` is undefined as a signed
// operation, and INT64_MIN reaches here through the whole-integer
// `Rational{value, DecimalPlaces{n}}` constructor, which does not
// canonicalise (and `numerator` is public). `absU64` is the shared helper
// that gets this right.
// operation. Every Rational constructor now canonicalises, so no
// constructed value carries INT64_MIN -- but `numerator` is a public
// member, so a caller can still assign one, and a formatter must stay
// defined for whatever it is handed. `absU64` is the shared helper that
// gets this right -- see morph#496 and morph#537.
auto const num = ::morph::math::detail::absU64(value.numerator);
auto const den = static_cast<std::uint64_t>(value.denominator);
auto const places = static_cast<std::uint32_t>(value.decimalPlaces.value);
Expand Down
Loading