morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
rational.hpp File Reference

Exact rational arithmetic for morph model/action values. More...

#include <cassert>
#include <cmath>
#include <compare>
#include <concepts>
#include <cstdint>
#include <expected>
#include <format>
#include <glaze/glaze.hpp>
#include <limits>
#include <morph/core/logger.hpp>
#include <morph/core/payload_shape_tag.hpp>
#include <numeric>
#include <string>
#include <string_view>
#include <type_traits>
#include <utility>

Go to the source code of this file.

Classes

struct  morph::math::DecimalPlaces
 Strong type for a decimal-precision count. More...
struct  morph::math::Numerator
 Strong type for a Rational numerator. More...
struct  morph::math::Denominator
 Strong type for a Rational denominator. More...
class  morph::math::WireClampScope
 Observes whether any Rational had to be clamped while decoding inside this scope. More...
struct  morph::math::Rational
 Exact rational number with a runtime decimal-precision tag. More...
struct  morph::math::Rational::Wire
 Flat JSON representation used by the Glaze codec below. More...
struct  morph::model::PayloadShapeTag< morph::math::Rational >
 Stable shape tag for Rational, so a payload fingerprint can tell a Rational field from a Timestamp or a Quantity one. More...

Enumerations

enum class  morph::math::RationalError : std::uint8_t { DivisionByZero , NotFinite , Overflow }
 Error states reachable through Rational operations. More...
enum class  morph::math::RoundingMode : std::uint8_t { HalfAwayFromZero , HalfEven }
 Rule for resolving a value sitting exactly halfway between the two nearest results when rounding to a decimal scale. More...

Functions

constexpr Rational morph::math::abs (const Rational &value) noexcept
 Absolute value of a rational. Precision is preserved.
constexpr std::int64_t morph::math::ceil (const Rational &value) noexcept
 Rounds toward positive infinity.
constexpr std::int64_t morph::math::floor (const Rational &value) noexcept
 Rounds toward negative infinity.
constexpr std::int64_t morph::math::trunc (const Rational &value) noexcept
 Truncates toward zero.
constexpr Rational morph::math::roundToDecimalPlaces (const Rational &value, DecimalPlaces places, RoundingMode mode=RoundingMode::HalfAwayFromZero) noexcept
 Rounds to a fixed number of decimal places exactly, without leaving the Rational domain – the operation ceil/floor/trunc do not offer, since those return std::int64_t and so only ever round to zero decimal places.
constexpr Rational morph::math::operator+ (const Rational &lhs, const Rational &rhs) noexcept
 Adds two Rationals. Result precision is max of the two.
constexpr Rational morph::math::operator- (const Rational &lhs, const Rational &rhs) noexcept
 Subtracts two Rationals. Result precision is max of the two.
constexpr Rational morph::math::operator* (const Rational &lhs, const Rational &rhs) noexcept
 Multiplies two Rationals. Result precision is max of the two.
constexpr std::expected< Rational, RationalError > morph::math::operator/ (const Rational &lhs, const Rational &rhs) noexcept
 Divides two Rationals. Result precision is max of the two.
constexpr std::expected< Rational, RationalError > morph::math::checkedAdd (const Rational &lhs, const Rational &rhs) noexcept
 Adds two Rationals, reporting overflow instead of saturating.
constexpr std::expected< Rational, RationalError > morph::math::checkedSub (const Rational &lhs, const Rational &rhs) noexcept
 Subtracts two Rationals, reporting overflow instead of saturating.
constexpr std::expected< Rational, RationalError > morph::math::checkedMul (const Rational &lhs, const Rational &rhs) noexcept
 Multiplies two Rationals, reporting overflow instead of saturating.
constexpr std::expected< Rational, RationalError > morph::math::checkedDiv (const Rational &lhs, const Rational &rhs) noexcept
 Divides two Rationals, reporting overflow instead of saturating.
template<typename Left, typename Right>
requires (detail::RationalLike<Left> || detail::RationalLike<Right>) && (detail::NeedsLifting<Left> || detail::NeedsLifting<Right>) &&
(detail::LiftableOperand<Left> && detail::LiftableOperand<Right>)
detail::ExpectedRational morph::math::operator+ (const Left &lhs, const Right &rhs) noexcept
 Mixed-type addition with automatic expected-propagation.
template<typename Left, typename Right>
requires (detail::RationalLike<Left> || detail::RationalLike<Right>) && (detail::NeedsLifting<Left> || detail::NeedsLifting<Right>) &&
(detail::LiftableOperand<Left> && detail::LiftableOperand<Right>)
detail::ExpectedRational morph::math::operator- (const Left &lhs, const Right &rhs) noexcept
 Mixed-type subtraction with automatic expected-propagation.
template<typename Left, typename Right>
requires (detail::RationalLike<Left> || detail::RationalLike<Right>) && (detail::NeedsLifting<Left> || detail::NeedsLifting<Right>) &&
(detail::LiftableOperand<Left> && detail::LiftableOperand<Right>)
detail::ExpectedRational morph::math::operator* (const Left &lhs, const Right &rhs) noexcept
 Mixed-type multiplication with automatic expected-propagation.
template<typename Left, typename Right>
requires (detail::RationalLike<Left> || detail::RationalLike<Right>) && (detail::NeedsLifting<Left> || detail::NeedsLifting<Right>) &&
(detail::LiftableOperand<Left> && detail::LiftableOperand<Right>)
detail::ExpectedRational morph::math::operator/ (const Left &lhs, const Right &rhs) noexcept
 Mixed-type division with automatic expected-propagation.

Variables

constexpr std::uint32_t morph::math::kMaxDecimalPlaces = 18
 Largest decimal precision the type supports: 10^18 is the greatest power of ten that fits in std::int64_t.

Detailed Description

Exact rational arithmetic for morph model/action values.

morph::math::Rational is a small, value-semantic, trivially-copyable struct representing the rational number numerator/denominator with std::int64_t components. It carries a runtime decimal-precision tag, decimalPlaces, as a strong type (DecimalPlaces). Arithmetic is exact — sums, differences, products, and quotients are reduced to canonical form with no floating-point rounding error. The precision tag affects only decimal scaling (Rational::fromFloat) and rounding (Rational::toDouble, formatting); it never changes a stored value.

Adapted from LASTRADA JPMath/Rational.hpp, with the boxed strong-type dependency replaced by a self-contained DecimalPlaces and a Glaze wire codec added so the type round-trips through the morph JSON wire with its invariants restored on read.

The precision is supplied at construction. There is intentionally no default — every call site states the precision it intends, e.g. Rational{1, 3, DecimalPlaces{9}}. Precision is capped at kMaxDecimalPlaces (18, the largest k for which 10^k fits in int64_t); out-of-range values assert in debug and clamp into [0, kMaxDecimalPlaces] in release. Zero decimal places is a legal precision — a whole-number quantity (e.g. a JPY/KRW amount) carries no fractional digit at all.

Cross-precision arithmetic
Binary arithmetic propagates std::max of the two operands' decimalPlaces — the wider precision wins. Comparison (<=>, ==) is purely value-based on the canonical (numerator, denominator) pair and ignores decimalPlaces entirely.
Invariants
Every public operation that produces a Rational restores:
  • denominator > 0 (strictly positive — never zero, never negative)
  • gcd(|numerator|, denominator) == 1
  • canonical zero is 0/1
  • 0 <= decimalPlaces.value <= kMaxDecimalPlaces

All sign lives in the numerator.

Error handling
The struct never throws. Operations that may fail (zero divisor, non-finite floating-point input, overflow during decimal scaling) return std::expected<Rational, RationalError>. operator/ and reciprocal return expected; operator+, operator-, operator* on plain Rational pairs cannot fail and return a bare Rational.

Overflow is not one of the conditions those channels report: all four operations saturate at ±INT64_MAX/1 and log, and operator/'s expected reports a zero divisor only — an overflowing quotient still comes back as a success holding a clamped value. checkedAdd, checkedSub, checkedMul and checkedDiv are the exact-or-nothing forms for callers that must branch on it.

Mixed-type expressions
Whenever an arithmetic expression contains an std::expected<Rational, RationalError> sub-expression or a floating-point operand, the whole expression evaluates to std::expected<Rational, RationalError>. The float operand is lifted via Rational::fromFloat — its precision is taken from the Rational operand's getDecimalPlaces(). Errors short-circuit left to right.
auto const a = Rational{7, 2, DecimalPlaces{9}};
auto const b = Rational{2, DecimalPlaces{9}};
auto const c = Rational{1, 2, DecimalPlaces{9}};
auto result = a / b + c * 3.5;
// decltype(result) == std::expected<Rational, RationalError>
Strong type for a decimal-precision count.
Definition rational.hpp:121
Exact rational number with a runtime decimal-precision tag.
Definition rational.hpp:375
Formatting
std::format support is split by the supplied spec:
  • empty spec "{}" -> exact rational form ("n/d", or "n" when integer)
  • non-empty spec "{:.3f}" -> delegated to std::formatter<double> on toDouble()
Wire format
Over the morph JSON wire a Rational travels as the object {"num":617,"den":50,"dp":2}. Reading goes through the canonicalising constructor, so a non-canonical payload (1234/100) or a hostile one (den == 0, out-of-range dp) always lands as a valid, reduced value.
Note
Negating a Rational built from INT64_MIN overflows; avoid that extreme value in code (the wire codec clamps it away for untrusted input).
Comparison is exact over the full int64 range (128-bit cross products). Arithmetic, however, has the usual fixed-width envelope: +, -, * overflow int64 when reduced cross terms exceed ~2^63 (e.g. sums over large coprime denominators). Keep operands within the decimal-scaled ranges the precision tags imply.

Definition in file rational.hpp.

Enumeration Type Documentation

◆ RationalError

enum class morph::math::RationalError : std::uint8_t
strong

Error states reachable through Rational operations.

Enumerator
DivisionByZero 

Divisor numerator is zero (operator/, reciprocal, From).

NotFinite 

Floating-point input was NaN or +/-Inf (fromFloat only).

Overflow 

Result or an intermediate term exceeds int64_t range (fromFloat, and the checked* arithmetic helpers checkedAdd/checkedSub/checkedMul/checkedDiv). Never produced by dividedBy/operator/, which saturate on overflow and report only DivisionByZero.

Definition at line 170 of file rational.hpp.

◆ RoundingMode

enum class morph::math::RoundingMode : std::uint8_t
strong

Rule for resolving a value sitting exactly halfway between the two nearest results when rounding to a decimal scale.

morph's decimal display path (morph::units::detail::formatRationalDecimal) rounds half away from zero, and that is why it is the default here: a value reconciled to its declared precision with HalfAwayFromZero renders as the same digits the submitter was shown before sending it. HalfEven exists for a caller bound to banker's rounding (the default in IEEE 754-2008 decimal, Python decimal and JSR-354, because half-away-from-zero biases a long sum upward) and knowingly trades that agreement away.

Enumerator
HalfAwayFromZero 

Ties go to the neighbour of larger magnitude: 1.25 -> 1.3, -1.25 -> -1.3.

HalfEven 

Ties go to the neighbour whose last kept digit is even: 1.25 -> 1.2, 1.35 -> 1.4.

Definition at line 190 of file rational.hpp.

Function Documentation

◆ abs()

Rational morph::math::abs ( const Rational & value)
nodiscardconstexprnoexcept

Absolute value of a rational. Precision is preserved.

Parameters
valueValue to take the absolute value of.
Returns
The non-negative value with the same magnitude.

Definition at line 976 of file rational.hpp.

◆ ceil()

std::int64_t morph::math::ceil ( const Rational & value)
nodiscardconstexprnoexcept

Rounds toward positive infinity.

Parameters
valueValue to round.
Returns
The smallest integer >= value.

Definition at line 985 of file rational.hpp.

◆ floor()

std::int64_t morph::math::floor ( const Rational & value)
nodiscardconstexprnoexcept

Rounds toward negative infinity.

Parameters
valueValue to round.
Returns
The largest integer <= value.

Definition at line 997 of file rational.hpp.

◆ trunc()

std::int64_t morph::math::trunc ( const Rational & value)
nodiscardconstexprnoexcept

Truncates toward zero.

Parameters
valueValue to truncate.
Returns
The integer part of the value.

Definition at line 1009 of file rational.hpp.

◆ roundToDecimalPlaces()

Rational morph::math::roundToDecimalPlaces ( const Rational & value,
DecimalPlaces places,
RoundingMode mode = RoundingMode::HalfAwayFromZero )
nodiscardconstexprnoexcept

Rounds to a fixed number of decimal places exactly, without leaving the Rational domain – the operation ceil/floor/trunc do not offer, since those return std::int64_t and so only ever round to zero decimal places.

This changes the value, not merely the decimalPlaces tag: the result is the multiple of 10^-places nearest value, ties resolved by mode, and it is tagged at places so display and storage agree digit for digit. A value already representable at places decimals is returned unchanged except for that tag, so the common case is exact and cannot overflow.

Saturates rather than overflowing, like every other Rational arithmetic path: rounding scales by 10^places, so a value whose scaled form exceeds int64 range clamps to +/-INT64_MAX and logs at error (see operator*=). A caller who must detect that has to range-check first – there is no checkedRound.

Parameters
valueValue to round.
placesTarget decimal places; silently clamped to [0, kMaxDecimalPlaces].
modeTie-breaking rule. Defaults to half away from zero, matching morph's display path.
Returns
The rounded value, tagged at places.

Definition at line 1380 of file rational.hpp.

◆ operator+() [1/2]

Rational morph::math::operator+ ( const Rational & lhs,
const Rational & rhs )
nodiscardconstexprnoexcept

Adds two Rationals. Result precision is max of the two.

Parameters
lhsLeft operand.
rhsRight operand.
Returns
The exact sum.

Definition at line 1046 of file rational.hpp.

◆ operator-() [1/2]

Rational morph::math::operator- ( const Rational & lhs,
const Rational & rhs )
nodiscardconstexprnoexcept

Subtracts two Rationals. Result precision is max of the two.

Parameters
lhsLeft operand.
rhsRight operand.
Returns
The exact difference.

Definition at line 1056 of file rational.hpp.

◆ operator*() [1/2]

Rational morph::math::operator* ( const Rational & lhs,
const Rational & rhs )
nodiscardconstexprnoexcept

Multiplies two Rationals. Result precision is max of the two.

Parameters
lhsLeft operand.
rhsRight operand.
Returns
The exact product.

Definition at line 1066 of file rational.hpp.

◆ operator/() [1/2]

std::expected< Rational, RationalError > morph::math::operator/ ( const Rational & lhs,
const Rational & rhs )
nodiscardconstexprnoexcept

Divides two Rationals. Result precision is max of the two.

Saturates on overflow and still reports success; see Rational::dividedBy, and checkedDiv for the exact-or-nothing form.

Parameters
lhsDividend.
rhsDivisor.
Returns
lhs / rhs — possibly saturated — or unexpected(DivisionByZero) if rhs is zero.

Definition at line 1080 of file rational.hpp.

◆ checkedAdd()

std::expected< Rational, RationalError > morph::math::checkedAdd ( const Rational & lhs,
const Rational & rhs )
nodiscardconstexprnoexcept

Adds two Rationals, reporting overflow instead of saturating.

operator+ saturates and logs when the exact sum does not fit, so it is never undefined – but it is also silently inexact. This returns the overflow instead, for callers that must not absorb it: a ledger totalling rows needs to stop, not to carry on with a clamped balance.

Every intermediate is checked before any is formed, since detecting signed overflow by performing it is itself undefined. Note the cross-terms are the tighter bound: they can overflow while the final result would have been perfectly representable, which is exactly the case a caller cannot spot by inspecting the answer.

Parameters
lhsLeft addend.
rhsRight addend.
Returns
The exact sum, or unexpected(RationalError::Overflow).

Definition at line 1101 of file rational.hpp.

◆ checkedSub()

std::expected< Rational, RationalError > morph::math::checkedSub ( const Rational & lhs,
const Rational & rhs )
nodiscardconstexprnoexcept

Subtracts two Rationals, reporting overflow instead of saturating.

The checkedAdd counterpart; see that function.

Parameters
lhsMinuend.
rhsSubtrahend.
Returns
The exact difference, or unexpected(RationalError::Overflow).

Definition at line 1117 of file rational.hpp.

◆ checkedMul()

std::expected< Rational, RationalError > morph::math::checkedMul ( const Rational & lhs,
const Rational & rhs )
nodiscardconstexprnoexcept

Multiplies two Rationals, reporting overflow instead of saturating.

Checks the cross-cancelled factors operator* actually multiplies, not the raw operands – see Rational::mulWouldOverflow.

Parameters
lhsLeft factor.
rhsRight factor.
Returns
The exact product, or unexpected(RationalError::Overflow).

Definition at line 1134 of file rational.hpp.

◆ checkedDiv()

std::expected< Rational, RationalError > morph::math::checkedDiv ( const Rational & lhs,
const Rational & rhs )
nodiscardconstexprnoexcept

Divides two Rationals, reporting overflow instead of saturating.

The escape hatch division was missing (morph#206). dividedBy and operator/ already return std::expected, but they spend it on the zero divisor alone: an overflowing quotient saturates and comes back as a successful expected holding a clamp, so a caller doing the right thing and checking the result is told the division succeeded. This reports both failure modes through the one channel.

Division is multiplication by the divisor's reciprocal, so this is exactly checkedMul against rhs.reciprocal() — the same operand pair dividedBy forms internally, checked by the same mulWouldOverflow predicate the operator uses. The operators and the checked forms therefore cannot disagree about which quotients fit.

Parameters
lhsDividend.
rhsDivisor.
Returns
The exact quotient; unexpected(RationalError::DivisionByZero) if rhs is zero; unexpected(RationalError::Overflow) if the quotient is not representable.

Definition at line 1164 of file rational.hpp.

◆ operator+() [2/2]

template<typename Left, typename Right>
requires (detail::RationalLike<Left> || detail::RationalLike<Right>) && (detail::NeedsLifting<Left> || detail::NeedsLifting<Right>) &&
(detail::LiftableOperand<Left> && detail::LiftableOperand<Right>)
detail::ExpectedRational morph::math::operator+ ( const Left & lhs,
const Right & rhs )
inlinenodiscardnoexcept

Mixed-type addition with automatic expected-propagation.

Parameters
lhsLeft operand (Rational, expected-Rational, or floating point).
rhsRight operand (Rational, expected-Rational, or floating point).
Returns
The exact sum, or the first error encountered left to right.

Definition at line 1274 of file rational.hpp.

◆ operator-() [2/2]

template<typename Left, typename Right>
requires (detail::RationalLike<Left> || detail::RationalLike<Right>) && (detail::NeedsLifting<Left> || detail::NeedsLifting<Right>) &&
(detail::LiftableOperand<Left> && detail::LiftableOperand<Right>)
detail::ExpectedRational morph::math::operator- ( const Left & lhs,
const Right & rhs )
inlinenodiscardnoexcept

Mixed-type subtraction with automatic expected-propagation.

Parameters
lhsLeft operand (Rational, expected-Rational, or floating point).
rhsRight operand (Rational, expected-Rational, or floating point).
Returns
The exact difference, or the first error encountered left to right.

Definition at line 1290 of file rational.hpp.

◆ operator*() [2/2]

template<typename Left, typename Right>
requires (detail::RationalLike<Left> || detail::RationalLike<Right>) && (detail::NeedsLifting<Left> || detail::NeedsLifting<Right>) &&
(detail::LiftableOperand<Left> && detail::LiftableOperand<Right>)
detail::ExpectedRational morph::math::operator* ( const Left & lhs,
const Right & rhs )
inlinenodiscardnoexcept

Mixed-type multiplication with automatic expected-propagation.

Parameters
lhsLeft operand (Rational, expected-Rational, or floating point).
rhsRight operand (Rational, expected-Rational, or floating point).
Returns
The exact product, or the first error encountered left to right.

Definition at line 1306 of file rational.hpp.

◆ operator/() [2/2]

template<typename Left, typename Right>
requires (detail::RationalLike<Left> || detail::RationalLike<Right>) && (detail::NeedsLifting<Left> || detail::NeedsLifting<Right>) &&
(detail::LiftableOperand<Left> && detail::LiftableOperand<Right>)
detail::ExpectedRational morph::math::operator/ ( const Left & lhs,
const Right & rhs )
inlinenodiscardnoexcept

Mixed-type division with automatic expected-propagation.

Parameters
lhsDividend (Rational, expected-Rational, or floating point).
rhsDivisor (Rational, expected-Rational, or floating point).
Returns
The exact quotient, or the first error encountered left to right.

Definition at line 1322 of file rational.hpp.

Variable Documentation

◆ kMaxDecimalPlaces

std::uint32_t morph::math::kMaxDecimalPlaces = 18
inlineconstexpr

Largest decimal precision the type supports: 10^18 is the greatest power of ten that fits in std::int64_t.

Definition at line 167 of file rational.hpp.