Skip to content

math: make Rational arithmetic total — saturating operators plus checked add/sub/mul - #153

Merged
Yaraslaut merged 5 commits into
masterfrom
rational-checked-arithmetic
Aug 23, 2026
Merged

math: make Rational arithmetic total — saturating operators plus checked add/sub/mul#153
Yaraslaut merged 5 commits into
masterfrom
rational-checked-arithmetic

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Aug 22, 2026

Copy link
Copy Markdown
Member

Refs #130. Also closes the UB in #156.

Rational's arithmetic was fixed-width int64 that neither saturated nor reported failure. At ledger-realistic magnitudes that's reachable — summing dp=2 legs of 10⁹ minor units overflows at exactly INT64_MAX / 10⁹ + 1 rows — and signed overflow is undefined behaviour, not a wrong-but-detectable answer.

Revised after review

The first version added checkedAdd/checkedSub/checkedMul alongside the operators. As you pointed out, that left the type still misusable into UB: opt-in safety isn't safety, because the operators were unchanged and anyone using + still got UB.

The operators now delegate to the same checks. operator+=/-=/*= run the overflow predicate first; on overflow they log at error and saturate to the largest representable magnitude of the correct sign, instead of overflowing.

The sign comes from exact comparison, not the operands' magnitudes — a + b compares a against -b using the type's own exact 128-bit-wide comparison. So a mixed-sign case whose cross-terms overflow still saturates in the mathematically correct direction:

expression result
INT64_MAX + 1 +INT64_MAX
-INT64_MAX - 2 -INT64_MAX
INT64_MAX * 3 +INT64_MAX
INT64_MAX * -3 -INT64_MAX
1/INT64_MAX + 1/(INT64_MAX-1) +INT64_MAX (tiny positive value, unrepresentable common denominator)

Saturating rather than throwing because these operators run inside strand-bound model code and in constexpr expressions: throwing would change their contract for every existing caller, while leaving the overflow undefined is the thing being fixed. A clamped value is wrong, but it is defined wrong, and it is logged.

Division of labour

The operators stay usable and defined for code that can absorb a clamped value. checkedAdd/checkedSub/checkedMul stay exact-or-nothing for code that must not — a ledger totalling rows needs to stop, not carry on with a clamped balance. Both share one set of predicates (addWouldOverflow, subWouldOverflow, mulWouldOverflow), so the operators and the checked forms cannot disagree about what overflows.

Fixing the operators exposed the same bug one level down

canonicalise() negated the numerator unguarded, so an INT64_MIN component was UB — and it was reachable not only by constructing such a value (#156) but by ordinary arithmetic landing on it exactly. -INT64_MAX - 1 is a legal subtraction whose result is INT64_MIN, and the subtraction itself does not overflow; the UB was in canonicalising the perfectly valid result.

It now clamps such a component to -INT64_MAX and logs, matching what setWire already did for the same values arriving off the wire, and takes magnitudes through the existing detail::absU64 helper so no negation can overflow. That resolves #156's UB, choosing its option 1 (clamp, mirroring setWire) — worth a look, since the issue listed three and this picks one.

Verification

Under UBSan, every case that previously reported signed integer overflow or negation of -9223372036854775808 now logs and saturates with no diagnostic at all.

Tests assert the saturated values, the sign in each direction, the intermediate-only overflow case, that ordinary arithmetic is undisturbed, and — via ScopedLoggerOverride — that the log actually fires.

Full suite 20,206 assertions / 1,092 cases. gcc -Wall -Wextra -Werror and clang -Wdocumentation both clean locally.

noexcept is preserved (from your review)

The operators keep noexcept, including on the logging path. That needs a local try/catch around the log call, because morph::log offers no noexcept guaranteedocs/spec/core/logger.md makes sink-propagation explicit ("a sink that must not disrupt its caller has to swallow its own exceptions internally"), and detail::log's own scoped_lock can throw std::system_error regardless of the sink. An arithmetic operator must not begin failing because logging failed.

CompletionState's destructor already carries the identical workaround, with a bugprone-empty-catch suppression and the comment "logError may throw; we swallow to avoid noexcept-escape" — so this is the second site paying the same per-call-site tax. Filed as #158: the logging layer should offer the guarantee once. Both workarounds come out if that lands.

Tested rather than asserted: static_assert on each operator's noexcept, plus a ScopedLoggerOverride installing a sink that throws on every call, driving all three overflow paths and the canonicalise clamp. Nothing escapes, and the values still saturate correctly.

No VERSIONING.md change is needed as a result — the operators' exception specification is unchanged from master.

Rational's arithmetic operators are fixed-width int64 and neither saturate
nor report failure. At ledger-realistic magnitudes that is reachable: summing
dp=2 legs of 10^9 minor units overflows at exactly INT64_MAX/10^9 + 1 rows,
and signed overflow is undefined behaviour rather than a wrong-but-detectable
answer, so an application has no way to notice before committing corrupted
state.

Adds checkedAdd/checkedSub/checkedMul returning
expected<Rational, RationalError>, plus the detail::addOverflows/
subOverflows/mulOverflows predicates they rest on. The predicates answer from
the operands rather than by performing the operation and inspecting the
result, which for signed types is itself undefined; mulOverflows is
division-based rather than a wide-product comparison so it stays valid in a
constant expression on MSVC, which has no __int128.

The intermediate cross-terms turn out to be the binding constraint, not the
result: checkedAdd rejects pairs whose final value would have been perfectly
representable but which cannot be reached without an intermediate that
overflows -- 1/INT64_MAX + 1/(INT64_MAX-1), a tiny value with an
unrepresentable common denominator. That is exactly the case a caller cannot
detect by inspecting the answer, because unchecked there is no valid answer
to inspect. Confirmed with UBSan: the unchecked operator+ on those operands
reports "signed integer overflow: 9223372036854775806 + 9223372036854775807",
while checkedAdd reports Overflow instead.

checkedMul checks the cross-cancelled factors operator* actually multiplies
rather than the raw operands, since cross-cancelling is what keeps most
products in range -- checking beforehand would reject INT64_MAX/2 * 2/1,
which reduces to INT64_MAX/1 and multiplies fine.

The unchecked operators are unchanged and stay the default; nothing existing
changes behaviour. Full suite 20,185 assertions / 1,086 cases.

Refs #130

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@Yaraslaut Yaraslaut left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we need to use his functions in the arithmetic operations for the Rational and safely unwrap them to provide valid results, we want to make save this type, while this addition adds save functionality it is still possible to misuse and get UB using this type, we should deligate to this functions from the arithmetic operations and log when it is failing

@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.47619% with 14 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
include/morph/util/rational.hpp 90.47% 2 Missing and 12 partials ⚠️

📢 Thoughts on this report? Let us know!

The gcc, MSVC and Valgrind legs aborted on 'checkedSub mirrors checkedAdd'
while clang passed. The test was at fault, not checkedSub: it constructed
Rational{Numerator{INT64_MIN}, ...}, and canonicalise() negates the numerator
unguarded, so -INT64_MIN is signed-overflow UB before any arithmetic runs.

Isolated with UBSan down to construction alone:
  rational.hpp:562: negation of -9223372036854775808 cannot be represented

setWire guards exactly this case on the wire path; the public constructor
does not. That is a separate pre-existing defect and is filed on its own --
this commit only stops the test from depending on it, using kMin + 1 and a
difference of 2 (kMin + 1 - 1 is exactly INT64_MIN, which still fits, so 2 is
the first that genuinely does not).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Yaraslaut

Copy link
Copy Markdown
Member Author

Fixed the gcc/MSVC/Valgrind failures — the test was wrong, and it found something

Those legs aborted on checkedSub mirrors checkedAdd while clang passed. checkedSub was not at fault: my test constructed Rational{Numerator{INT64_MIN}, ...}, and that is undefined behaviour on its owncanonicalise() negates the numerator unguarded, before any arithmetic runs.

Isolated with UBSan down to construction alone, nothing else in the program:

rational.hpp:562: runtime error: negation of -9223372036854775808
  cannot be represented in type 'std::int64_t'

setWire guards exactly this case on the wire path — its doc comment names "INT64_MIN components whose negation would overflow" — but the public constructor does not. Filed separately as #156, since it is a distinct pre-existing defect: #130 is about arithmetic overflowing, #156 is about a value that cannot be constructed at all.

This PR now only stops the test depending on it: kMin + 1 with a difference of 2 (since kMin + 1 - 1 is exactly INT64_MIN, which still fits, 2 is the first difference that genuinely does not). Verified under UBSan — clean, and checkedSub reports Overflow as intended.

Worth noting the shape of this: the checked-arithmetic work is what surfaced a UB hole in the constructor, which no amount of arithmetic testing would have found.

…erflow

Review feedback on #153: adding checked* alongside the operators left the
type still misusable into UB, because the operators themselves were
unchanged. The operators now delegate to the same checks.

operator+=/-=/*= run the overflow predicate first. On overflow they log at
error and clamp to the largest representable magnitude of the correct sign
instead of committing signed-overflow undefined behaviour. The sign comes
from exact comparison (a + b compares a against -b), not from the operands'
magnitudes, so a mixed-sign case whose cross-terms overflow still saturates
in the mathematically correct direction -- 1/INT64_MAX + 1/(INT64_MAX-1)
saturates positive, and INT64_MAX * -3 saturates negative.

Saturation rather than throwing: these operators run inside strand-bound
model code and in constexpr expressions, so throwing would change their
contract for every existing caller, while leaving the overflow undefined is
the thing being fixed. A clamped value is wrong, but defined wrong, and
logged. Callers that must not absorb it keep checkedAdd/checkedSub/checkedMul,
which now share the operators' predicates so the two cannot disagree about
what overflows.

Fixing the operators exposed that canonicalise() had the same class of bug
one level down: it negated the numerator unguarded, so an INT64_MIN
component was UB. That was reachable not only by constructing such a value
(#156) but by ordinary arithmetic landing on it exactly -- -INT64_MAX - 1 is
a legal subtraction whose result is INT64_MIN, and the subtraction itself
does not overflow. canonicalise now clamps such a component to -INT64_MAX
and logs, matching what setWire already did for the same values off the
wire, and takes magnitudes through the existing detail::absU64 helper so no
negation can overflow.

Verified with UBSan: every case that previously reported "signed integer
overflow" or "negation of -9223372036854775808" now logs and saturates
with no diagnostic. Tests assert the saturated values, the sign in each
direction, and that the log actually fires (via ScopedLoggerOverride).

Full suite 20,206 assertions / 1,092 cases; gcc -Wall -Wextra -Werror and
clang -Wdocumentation both clean.

Refs #130, #156

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Yaraslaut Yaraslaut changed the title math: add checked add/sub/mul for Rational (draft) math: make Rational arithmetic total — saturating operators plus checked add/sub/mul Aug 22, 2026
Yaraslaut and others added 2 commits August 22, 2026 22:46
Review point: an arithmetic operator should not lose noexcept just because it
logs. It no longer does.

morph::log offers no noexcept guarantee -- the spec makes sink-propagation
explicit ('a sink that must not disrupt its caller has to swallow its own
exceptions internally'), and detail::log's own scoped_lock can throw
std::system_error regardless of the sink. So reportOverflow/reportClamp
swallow locally, exactly as CompletionState's destructor already does for the
same reason, and operator+=/-=/*= carry noexcept again.

Tested rather than asserted: static_assert on each operator's noexcept, plus
a ScopedLoggerOverride installing a sink that throws on every call, driving
all three overflow paths and the canonicalise clamp. Nothing escapes, and the
values still saturate correctly.

The underlying contract is filed as morph#158 -- the logging layer offering
the guarantee once, rather than every noexcept caller hand-rolling a catch.
Both workarounds come out if that lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The clang legs failed with -Wzero-as-null-pointer-constant: comparing a
std::strong_ordering against 0 makes the literal a null-pointer constant
under -Weverything, which this repository builds with. My local test build
does not apply that flag set, so it passed here and failed on all 17 CI legs.

std::is_lt/std::is_gt is also what operator<=> in this same file already
uses, so this matches the house idiom rather than inventing one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Yaraslaut
Yaraslaut marked this pull request as ready for review August 22, 2026 20:46
@Yaraslaut
Yaraslaut merged commit 83a6796 into master Aug 23, 2026
34 checks passed
Yaraslaut added a commit that referenced this pull request Aug 23, 2026
…ide Rational

Review feedback: rejecting malformed wire input is not Rational's job. The
type should round-trip what it can represent; handling a payload that
arrived malformed belongs to the layer that decoded it.

Removed from Rational: WirePolicy, WireValidationError, setWirePolicy/
wirePolicy, ScopedWirePolicy, and the throw from setWire. setWire is plain
noexcept clamping again.

What Rational keeps is only what it alone knows:
- Wire::validate(), the representability predicate, written with
  std::in_range over the component magnitudes rather than `!= INT64_MIN`.
  The real requirement is that a component can be negated, which is to say
  its magnitude fits, and in_range says that directly.
- WireClampScope, a scoped count of clamps during a decode.

Tracing where such a payload actually arrives answered where the decision
belongs. morph::wire carries an execute envelope's `body` as an opaque
string and never parses it -- wire.hpp says as much ("payload smuggled
*inside* `body` is invisible to any structural/depth check") -- so
ActionTraits<A>::fromJson is the first and only place a Rational inside
that body is decoded. That is the codec boundary, and it now rejects a
clamped payload with ParseError, the error type it already throws for a
malformed body. Both fromJson macro expansions carry it.

The round-trip property is tested directly: six representable values, each
written and read back, checked for equality, component-wise identity, and
byte-identical re-encoding.

Rebased onto master after #153 landed. The two interact cleanly: #153 made
canonicalise() total by clamping an INT64_MIN component, and a wire value
carrying one now also fails Wire::validate(), so the decode is rejected
before that clamp is reachable from the wire at all.

Mutation-verified: disabling the boundary check fails 4 assertions. Full
suite 20,307 assertions / 1,107 cases against the rebased master; docs gate
and gcc/-Weverything clean locally.

Closes #131

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rational's constructor is undefined behaviour for numerator == INT64_MIN; setWire guards this, the public constructor does not

1 participant