Compare commits

...

15 Commits

Author SHA1 Message Date
Pratik Mankawde
66eb04171a Merge develop into pratik/ranged-normalize-number-at-construction 2026-10-02 09:17:53 +01:00
Pratik Mankawde
09b9cf8d6a docs: Cut the unverifiable clauses from normalizeToRange
The @note comparing this overload to the two-pass path stated where the two
diverge and called the first range "strictly wider". Both were wrong: they also
diverge below kMinExponent, and MantissaScale::Small is the IOU range exactly,
not wider. The comparison is not a contract a caller needs, so drop it rather
than reword it, and state only what the function returns.
2026-09-23 18:22:35 +01:00
Pratik Mankawde
2c2b747881 fix: Correct the zero sentinel and narrow the equivalence claim
Two review findings on the static normalizeToRange documentation, both
confirmed against the code.

The documented zero result was wrong. doNormalize copies the canonical zero
out of a default-constructed Number, and Number's exponent_ is initialised to
std::numeric_limits<int>::lowest(), not 0. normalizeToRangeImpl returns that
exponent unchanged, so a zero mantissa yields {0, lowest()}.

The "bit-identical to the two-pass path" note was unconditionally false. At
exponent == kMinExponent the scale-up loop cannot run, so a mantissa below the
wider range's minimum falls into doNormalize's zero branch: building a Number
first collapses 10^17e-32768 to zero, while normalizing straight to the IOU
range scales down and returns {10^15, kMinExponent + 2}. The single pass is the
more accurate of the two, so the note is narrowed to the exponents IOUAmount
can actually reach rather than the code changed to reproduce the loss.

Add tests pinning both, since nothing covered a zero mantissa or the exponent
floor.
2026-09-23 15:41:00 +01:00
Pratik Mankawde
b41e92f7b3 chore: Satisfy pre-commit hooks on ranged normalizeToRange
Two hooks were failing the run-hooks job.

fix-gtest-names requires a CamelCase suite and a snake_case test case, so
rename the four new test cases. They also sat on a `Number` suite of their
own while the rest of the file uses `NumberTest`, which meant a
`--gtest_filter=NumberTest.*` run silently skipped them; move them onto
`NumberTest` so one filter selects all 35.

clang-format reflowed the `@return` text of the static normalizeToRange
because the zero-mantissa sentence ran to 104 columns, over the 100-column
limit. Wrap that sentence by hand instead, and restore the `@note` line
recording that the result is bit-identical to the two-pass path: an earlier
edit replaced two lines with one and dropped it, leaving its continuation
"pass to a strictly wider range ..." as a sentence fragment with no subject.
2026-09-23 14:54:13 +01:00
Pratik Mankawde
d188083da3 Update documentation for mantissa and exponent return values
Clarified documentation regarding zero mantissa handling.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-23 14:35:33 +01:00
Pratik Mankawde
5b17c3369d Merge branch 'develop' into pratik/ranged-normalize-number-at-construction 2026-09-23 14:31:09 +01:00
Pratik Mankawde
54e052d8bb refactor: extract IOUAmount exponent bounds check into a helper
The exponent bounds check appeared verbatim in both normalize() and
IOUAmount(Number const&). Extract it into a private
enforceExponentBounds() and call it from both.

Named "enforce" rather than "check" because the function acts on the
value as well as inspecting it: it throws above the range and truncates
to zero below it. That matches the codebase precedent of requireAuth
(a pure predicate) versus enforceMPTokenAuthorization (which mutates).

Defined out of line because a header inline would need STAmount's
offset constants, and STAmount.h already includes IOUAmount.h.

No behavior change: the extracted body is identical to both original
blocks, and the Number constructor still applies it after delegating
construction completes.
2026-07-29 13:11:51 +01:00
Pratik Mankawde
b292d11797 minor missed check
Signed-off-by: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com>
2026-07-27 15:55:20 +01:00
Pratik Mankawde
b0c9ef48d1 Merge branch 'develop' into pratik/ranged-normalize-number-at-construction
Signed-off-by: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com>
2026-07-27 15:11:31 +01:00
Pratik Mankawde
fb75b4f70c code review comments
Signed-off-by: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com>
2026-06-09 16:57:23 +01:00
Pratik Mankawde
809b617bb7 Merge branch 'develop' into pratik/ranged-normalize-number-at-construction 2026-06-09 16:50:02 +01:00
Pratik Mankawde
0480d951e6 micro benchmark tests
Signed-off-by: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com>
2026-06-03 13:23:15 +01:00
Pratik Mankawde
14fef306dd clang-tidy fixes
Signed-off-by: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com>
2026-06-03 11:30:40 +01:00
Pratik Mankawde
d3d2cf0c9a Merge branch 'develop' into pratik/ranged-normalize-number-at-construction 2026-06-02 19:03:00 +01:00
Pratik Mankawde
87eb3fcf3b perf: Add single-pass ranged normalization to Number
IOUAmount::normalize() previously built a Number (one normalize pass to
the default Large range) and then re-normalized down to the narrower IOU
range via fromNumber (a second pass) -- two full passes where one would
do.

Add a static Number::normalizeToRange<Min,Max>(mantissa, exponent) that
normalizes raw integers straight to a target range in a single pass,
building no intermediate Number. Refactor the existing const member
overload to share one implementation, so both paths have a single source
of truth. Rewire the getSTNumberSwitchover()-true branch of
IOUAmount::normalize() to call the new primitive.

The result is bit-identical to the old two-pass path: an intermediate
pass to a strictly wider range cannot change the final narrower-range
result. Equivalence is proven by new GTests that sweep mantissa/exponent
boundaries, negatives, int64 extremes, rounding cusps, and all four
rounding modes against the prior two-pass result, plus exact-value
assertions on hand-computed cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:56:06 +01:00
4 changed files with 322 additions and 18 deletions

View File

@@ -594,7 +594,61 @@ public:
static InternalRep
externalToInternal(rep mantissa);
/**
* Normalize raw (mantissa, exponent) integers directly to a target range.
*
* This is the construction-time counterpart of the member overload above.
* Callers that hold raw integers (e.g. IOUAmount) and want them in a
* narrow range would otherwise build a Number (one normalize pass to the
* default kRange) and then call the member normalizeToRange (a second pass
* down to the narrow range). This overload does a single pass: it converts
* the signed mantissa to its internal magnitude and normalizes straight to
* [MinMantissa, MaxMantissa], building no intermediate Number.
*
* Data flow (single pass), contrasted with the old two-pass path:
*
* two-pass: (m,e) --build Number--> [kRange/Large] --member--> [Min,Max]
* one-pass: (m,e) -------------- normalize --------------> [Min,Max]
*
* @tparam MinMantissa Lower bound of the target mantissa range; must be a
* positive power of ten.
* @tparam MaxMantissa Upper bound; must equal MinMantissa * 10 - 1.
* @tparam T Result mantissa type, int64_t or uint64_t. Defaults
* to the type of MinMantissa.
* @param mantissa Raw signed mantissa (sign is extracted internally).
* @param exponent Raw exponent.
* @return The normalized (mantissa, exponent) pair in the target range.
* A zero mantissa returns {0, std::numeric_limits<int>::lowest()};
* the sign of a zero is not preserved.
* @note Thread-safety: reads the thread-local rounding mode only; holds no
* shared state of its own. Safe to call concurrently.
*
* Example (IOU range, 10^15 .. 10^16-1):
* @code
* auto [m, e] = Number::normalizeToRange<1'000'000'000'000'000,
* 9'999'999'999'999'999>(1, 0);
* // m == 1'000'000'000'000'000, e == -15
* @endcode
*/
template <
auto MinMantissa,
auto MaxMantissa,
Integral64 T = std::decay_t<decltype(MinMantissa)>>
[[nodiscard]]
static std::pair<T, int>
normalizeToRange(rep mantissa, int exponent);
private:
// Shared implementation for both normalizeToRange overloads. Takes the sign
// and internal (uint64) magnitude already separated, normalizes in place to
// [MinMantissa, MaxMantissa], and returns the signed (mantissa, exponent).
template <
auto MinMantissa,
auto MaxMantissa,
Integral64 T = std::decay_t<decltype(MinMantissa)>>
static std::pair<T, int>
normalizeToRangeImpl(bool negative, InternalRep mantissa, int exponent);
static thread_local RoundingMode mode;
// The available ranges for mantissa
@@ -839,7 +893,7 @@ Number::isnormal() const noexcept
template <auto MinMantissa, auto MaxMantissa, Integral64 T>
std::pair<T, int>
Number::normalizeToRange() const
Number::normalizeToRangeImpl(bool negative, InternalRep mantissa, int exponent)
{
static_assert(std::is_same_v<T, std::uint64_t> || std::is_same_v<T, std::int64_t>);
static_assert(std::is_same_v<T, std::decay_t<decltype(MinMantissa)>>);
@@ -852,10 +906,6 @@ Number::normalizeToRange() const
static_assert(kMAX % 10 == 9);
static_assert((kMAX + 1) / 10 == kMIN);
bool negative = negative_;
InternalRep mantissa = mantissa_;
int exponent = exponent_;
if constexpr (std::is_unsigned_v<T>)
{
XRPL_ASSERT_PARTS(
@@ -872,6 +922,26 @@ Number::normalizeToRange() const
return std::make_pair(static_cast<T>(sign * mantissa), exponent);
}
template <auto MinMantissa, auto MaxMantissa, Integral64 T>
std::pair<T, int>
Number::normalizeToRange() const
{
// Forward this Number's already-separated internal components to the shared
// implementation. Passing mantissa_ (which may exceed kMaxRep in the Large
// range) through unchanged keeps the result byte-identical to before.
return normalizeToRangeImpl<MinMantissa, MaxMantissa, T>(negative_, mantissa_, exponent_);
}
template <auto MinMantissa, auto MaxMantissa, Integral64 T>
std::pair<T, int>
Number::normalizeToRange(rep mantissa, int exponent)
{
// Separate sign and magnitude from the raw signed mantissa, then normalize
// straight to the target range in a single pass (no intermediate Number).
return normalizeToRangeImpl<MinMantissa, MaxMantissa, T>(
mantissa < 0, externalToInternal(mantissa), exponent);
}
constexpr Number
abs(Number x) noexcept
{

View File

@@ -40,6 +40,20 @@ private:
void
normalize();
/**
* Constrains the exponent to IOUAmount's range, which is narrower than
* the one Number normalization enforces.
*
* The two ends are deliberately asymmetric, matching the class contract:
* an exponent above the range is unrepresentable and throws, while one
* below it is a silent underflow that truncates the amount to zero.
*
* @throws std::overflow_error if the exponent exceeds the largest
* representable IOU exponent.
*/
void
enforceExponentBounds();
static IOUAmount
fromNumber(Number const& number);

View File

@@ -43,19 +43,7 @@ IOUAmount::minPositiveAmount()
}
void
IOUAmount::normalize()
{
if (mantissa_ == 0)
{
*this = beast::kZero;
return;
}
Number const v{mantissa_, exponent_};
*this = IOUAmount(v);
}
IOUAmount::IOUAmount(Number const& other) : IOUAmount(fromNumber(other))
IOUAmount::enforceExponentBounds()
{
if (exponent_ > kMaxExponent)
{
@@ -67,6 +55,27 @@ IOUAmount::IOUAmount(Number const& other) : IOUAmount(fromNumber(other))
}
}
void
IOUAmount::normalize()
{
if (mantissa_ == 0)
{
*this = beast::kZero;
return;
}
std::tie(mantissa_, exponent_) =
Number::normalizeToRange<kMinMantissa, kMaxMantissa>(mantissa_, exponent_);
// normalizeToRange only enforces Number's much wider exponent bounds, so
// IOUAmount's narrower range still has to be applied on top.
enforceExponentBounds();
}
IOUAmount::IOUAmount(Number const& other) : IOUAmount(fromNumber(other))
{
enforceExponentBounds();
}
IOUAmount&
IOUAmount::operator+=(IOUAmount const& other)
{

View File

@@ -3050,4 +3050,215 @@ TEST(NumberTest, number_cusp_rounding_with_fractional_parts)
}
}
// The IOUAmount mantissa range: [10^15, 10^16 - 1]. Kept here as signed
// constants so the default template parameter T resolves to std::int64_t,
// matching IOUAmount's own use of Number::normalizeToRange.
constexpr std::int64_t kMin = 1'000'000'000'000'000;
constexpr std::int64_t kMax = (kMin * 10) - 1;
// The two-pass path that the static primitive replaces: build a Number (one
// normalize pass to the default range) and then re-normalize to the narrow IOU
// range via the const member overload (a second pass).
std::pair<std::int64_t, int>
twoPass(std::int64_t mantissa, int exponent)
{
Number const v{mantissa, exponent};
return v.normalizeToRange<kMin, kMax>();
}
// The single-pass static primitive under test.
std::pair<std::int64_t, int>
onePass(std::int64_t mantissa, int exponent)
{
return Number::normalizeToRange<kMin, kMax>(mantissa, exponent);
}
// The static primitive must produce bit-identical (mantissa, exponent) to the
// old two-pass path across a broad sweep of inputs: values needing scale-up,
// scale-down, rounding cusps, negatives, and exponent extremes.
TEST(NumberTest, normalize_to_range_equivalence)
{
// A spread of mantissa magnitudes: tiny (heavy scale-up), mid, at the IOU
// floor/ceiling, beyond it (scale-down), and int64 extremes.
std::int64_t const mantissas[] = {
1,
2,
7,
9,
99,
100,
12345,
999'999'999'999'999,
kMin,
kMin + 1,
kMax,
kMax + 1,
1'234'567'890'123'456,
12'345'678'901'234'567,
std::numeric_limits<std::int64_t>::max(),
std::numeric_limits<std::int64_t>::max() - 1,
};
for (std::int64_t const absM : mantissas)
{
for (std::int64_t const m : {absM, -absM})
{
for (int const e : {-90, -32, -1, 0, 1, 5, 32, 70})
{
auto const expected = twoPass(m, e);
auto const actual = onePass(m, e);
EXPECT_EQ(actual.first, expected.first)
<< "mantissa mismatch for m=" << m << " e=" << e;
EXPECT_EQ(actual.second, expected.second)
<< "exponent mismatch for m=" << m << " e=" << e;
}
}
}
// int64::min cannot be negated naively; externalToInternal handles it. Make
// sure the static path agrees with the two-pass path on it too.
{
std::int64_t const m = std::numeric_limits<std::int64_t>::min();
auto const expected = twoPass(m, 0);
auto const actual = onePass(m, 0);
EXPECT_EQ(actual.first, expected.first);
EXPECT_EQ(actual.second, expected.second);
}
}
// Exact, hand-computed results (state + cause), not just "equals the old path".
TEST(NumberTest, normalize_to_range_exact_values)
{
// A single digit scales up by 15 powers of ten to reach the floor 10^15,
// with the exponent dropping by the same 15.
{
auto const [m, e] = onePass(1, 0);
EXPECT_EQ(m, kMin); // 1'000'000'000'000'000
EXPECT_EQ(e, -15);
}
// Already exactly at the floor: unchanged.
{
auto const [m, e] = onePass(kMin, 4);
EXPECT_EQ(m, kMin);
EXPECT_EQ(e, 4);
}
// Already exactly at the ceiling: unchanged.
{
auto const [m, e] = onePass(kMax, -7);
EXPECT_EQ(m, kMax); // 9'999'999'999'999'999
EXPECT_EQ(e, -7);
}
// One past the ceiling scales down by one power of ten; the dropped ones
// digit (0) truncates cleanly and the exponent rises by one.
{
auto const [m, e] = onePass(kMax + 1, 0); // 10'000'000'000'000'000
EXPECT_EQ(m, kMin); // 1'000'000'000'000'000
EXPECT_EQ(e, 1);
}
// Negative values keep their sign through normalization.
{
auto const [m, e] = onePass(-5, 0);
EXPECT_EQ(m, -5 * kMin); // -5'000'000'000'000'000
EXPECT_EQ(e, -15);
}
// Zero mantissa: the workhorse leaves it as zero (callers special-case it).
{
auto const [m, e] = onePass(0, 0);
EXPECT_EQ(m, 0);
}
}
// Equivalence must hold under every rounding mode, not just the default
// ToNearest. This is the subtlest risk: the single-pass impl hardcodes
// CuspRoundingFix::Disabled, whereas the old two-pass path ran an intermediate
// normalize to the wider range first. Sweep all four modes, including inputs
// that round at a tie (a trailing digit of exactly 5 when scaling down).
TEST(NumberTest, normalize_to_range_all_rounding_modes)
{
// Inputs chosen so scale-down drops a non-zero (and tie) trailing digit.
std::int64_t const mantissas[] = {
15,
25,
12'345'678'901'234'565, // 17 digits, trailing 5 -> tie on the drop
99'999'999'999'999'995,
kMax + 5,
std::numeric_limits<std::int64_t>::max(),
};
for (auto mode :
{Number::RoundingMode::ToNearest,
Number::RoundingMode::TowardsZero,
Number::RoundingMode::Downward,
Number::RoundingMode::Upward})
{
for (std::int64_t const absM : mantissas)
{
for (std::int64_t const m : {absM, -absM})
{
for (int const e : {-20, 0, 13})
{
NumberRoundModeGuard const g(mode);
auto const expected = twoPass(m, e);
auto const actual = onePass(m, e);
EXPECT_EQ(actual.first, expected.first)
<< "mantissa mismatch: mode=" << static_cast<int>(mode) << " m=" << m
<< " e=" << e;
EXPECT_EQ(actual.second, expected.second)
<< "exponent mismatch: mode=" << static_cast<int>(mode) << " m=" << m
<< " e=" << e;
}
}
}
}
}
// The refactored const member overload must forward to the static primitive
// and yield identical results for the same Number.
TEST(NumberTest, normalize_to_range_member_static_consistency)
{
std::int64_t const mantissas[] = {3, 42, kMin, kMin + 7, kMax, kMax + 1, 1'234'567'890'123'456};
for (std::int64_t const absM : mantissas)
{
for (std::int64_t const m : {absM, -absM})
{
for (int const e : {-50, -3, 0, 11, 60})
{
Number const v{m, e};
auto const viaMember = v.normalizeToRange<kMin, kMax>();
// Feed the static the raw inputs that built the Number.
auto const viaStatic = Number::normalizeToRange<kMin, kMax>(m, e);
EXPECT_EQ(viaMember.first, viaStatic.first) << "m=" << m << " e=" << e;
EXPECT_EQ(viaMember.second, viaStatic.second) << "m=" << m << " e=" << e;
}
}
}
}
// A zero mantissa returns the zero sentinel, ignoring the exponent passed in.
TEST(NumberTest, normalize_to_range_zero_mantissa)
{
for (int const e : {Number::kMinExponent, -90, -1, 0, 1, 90, Number::kMaxExponent})
{
auto const [m, exponent] = onePass(0, e);
EXPECT_EQ(m, 0) << "e=" << e;
EXPECT_EQ(exponent, std::numeric_limits<int>::lowest()) << "e=" << e;
}
}
// At the exponent floor the two paths differ: the two-pass path zeroes, while
// the single pass scales down and keeps the value.
TEST(NumberTest, normalize_to_range_exponent_floor_diverges_from_two_pass)
{
// 10^17: above the IOU minimum, below the Large330 minimum of 10^18.
constexpr std::int64_t kBelowWideMin = kMin * 100;
auto const [oneM, oneE] = onePass(kBelowWideMin, Number::kMinExponent);
EXPECT_EQ(oneM, kMin);
EXPECT_EQ(oneE, Number::kMinExponent + 2);
auto const [twoM, twoE] = twoPass(kBelowWideMin, Number::kMinExponent);
EXPECT_EQ(twoM, 0);
EXPECT_EQ(twoE, std::numeric_limits<int>::lowest());
}
} // namespace xrpl