math: make Rational arithmetic total — saturating operators plus checked add/sub/mul - #153
Conversation
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
left a comment
There was a problem hiding this comment.
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 Report❌ Patch coverage is
📢 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>
Fixed the gcc/MSVC/Valgrind failures — the test was wrong, and it found somethingThose legs aborted on Isolated with UBSan down to construction alone, nothing else in the program:
This PR now only stops the test depending on it: 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>
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>
…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>
Refs #130. Also closes the UB in #156.
Rational's arithmetic was fixed-widthint64that neither saturated nor reported failure. At ledger-realistic magnitudes that's reachable — summing dp=2 legs of 10⁹ minor units overflows at exactlyINT64_MAX / 10⁹ + 1rows — and signed overflow is undefined behaviour, not a wrong-but-detectable answer.Revised after review
The first version added
checkedAdd/checkedSub/checkedMulalongside 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 aterrorand saturate to the largest representable magnitude of the correct sign, instead of overflowing.The sign comes from exact comparison, not the operands' magnitudes —
a + bcomparesaagainst-busing 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:INT64_MAX + 1+INT64_MAX-INT64_MAX - 2-INT64_MAXINT64_MAX * 3+INT64_MAXINT64_MAX * -3-INT64_MAX1/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
constexprexpressions: 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/checkedMulstay 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 anINT64_MINcomponent was UB — and it was reachable not only by constructing such a value (#156) but by ordinary arithmetic landing on it exactly.-INT64_MAX - 1is a legal subtraction whose result isINT64_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_MAXand logs, matching whatsetWirealready did for the same values arriving off the wire, and takes magnitudes through the existingdetail::absU64helper so no negation can overflow. That resolves #156's UB, choosing its option 1 (clamp, mirroringsetWire) — worth a look, since the issue listed three and this picks one.Verification
Under UBSan, every case that previously reported
signed integer overflowornegation of -9223372036854775808now 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 -Werrorand clang-Wdocumentationboth clean locally.noexceptis preserved (from your review)The operators keep
noexcept, including on the logging path. That needs a localtry/catcharound the log call, becausemorph::logoffers nonoexceptguarantee —docs/spec/core/logger.mdmakes sink-propagation explicit ("a sink that must not disrupt its caller has to swallow its own exceptions internally"), anddetail::log's ownscoped_lockcan throwstd::system_errorregardless of the sink. An arithmetic operator must not begin failing because logging failed.CompletionState's destructor already carries the identical workaround, with abugprone-empty-catchsuppression 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_asserton each operator'snoexcept, plus aScopedLoggerOverrideinstalling a sink that throws on every call, driving all three overflow paths and thecanonicaliseclamp. Nothing escapes, and the values still saturate correctly.No
VERSIONING.mdchange is needed as a result — the operators' exception specification is unchanged from master.