From b2c714a1b280b152235b85eae1050097d3bc5e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Wed, 12 Aug 2026 01:33:50 +0200 Subject: [PATCH] Trim long trailing zero suffixes in digit comparison Keep proof on the public from_chars path. Remove the direct digit_comp benchmark because no current claim or check uses it. Reviewed-by: Bentley Reviewed-by: Thompson --- CMakeLists.txt | 11 + benchmarks/trailing_zero_benchmark.cpp | 319 +++++++++++++++++++++++++ include/fast_float/digit_comparison.h | 117 +++++++++ include/fast_float/float_common.h | 7 +- include/fast_float/parse_number.h | 24 +- tests/BUILD.bazel | 9 + tests/CMakeLists.txt | 1 + tests/trailing_zeros_test.cpp | 231 ++++++++++++++++++ 8 files changed, 710 insertions(+), 9 deletions(-) create mode 100644 benchmarks/trailing_zero_benchmark.cpp create mode 100644 tests/trailing_zeros_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1ec61423..69fdf76d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,17 @@ target_include_directories( $ ) target_compile_features(fast_float INTERFACE cxx_std_11) + +# This focused benchmark has no external dependencies, so it can be built +# independently of the data-driven benchmark suite below. +option(FASTFLOAT_TRAILING_ZERO_BENCHMARK + "Build the trailing decimal zero slow-path benchmark" OFF) +if(FASTFLOAT_TRAILING_ZERO_BENCHMARK) + add_executable(trailing_zero_benchmark benchmarks/trailing_zero_benchmark.cpp) + target_link_libraries(trailing_zero_benchmark PRIVATE fast_float) + target_compile_features(trailing_zero_benchmark PRIVATE cxx_std_11) +endif() + if(FASTFLOAT_SANITIZE) target_compile_options(fast_float INTERFACE -fsanitize=address -fno-omit-frame-pointer -fsanitize=undefined -fno-sanitize-recover=all) target_link_libraries(fast_float INTERFACE -fsanitize=address -fno-omit-frame-pointer -fsanitize=undefined -fno-sanitize-recover=all) diff --git a/benchmarks/trailing_zero_benchmark.cpp b/benchmarks/trailing_zero_benchmark.cpp new file mode 100644 index 00000000..9440fb2e --- /dev/null +++ b/benchmarks/trailing_zero_benchmark.cpp @@ -0,0 +1,319 @@ +#include "fast_float/fast_float.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct input_case { + std::string input; + std::string canonical; +}; + +struct target_case { + fast_float::parsed_number_string number; + fast_float::adjusted_mantissa am; + + target_case() : number(), am() {} +}; + +volatile uint64_t sink = 0; + +std::string nonzero_tail(size_t length) { + std::string result(length, '1'); + result.front() = '7'; + result.back() = '7'; + return result; +} + +bool make_target_case(std::string const &input, target_case &target) { + fast_float::parse_options options; + fast_float::parsed_number_string const parsed = + fast_float::parse_number_string( + input.data(), input.data() + input.size(), options, true); + fast_float::adjusted_mantissa am = + fast_float::compute_float>( + parsed.exponent, parsed.mantissa); + if (!parsed.valid || !parsed.too_many_digits || am.power2 < 0 || + am == fast_float::compute_float>( + parsed.exponent, parsed.mantissa + 1)) { + return false; + } + am = fast_float::compute_error>( + parsed.exponent, parsed.mantissa); + if (am.power2 >= 0) { + return false; + } + target.number = parsed; + target.am = am; + return true; +} + +bool takes_digit_comp(std::string const &input) { + target_case target; + return make_target_case(input, target); +} + +uint64_t bits(double value) { + uint64_t result; + ::memcpy(&result, &value, sizeof(result)); + return result; +} + +void parse(std::string const &input, double &value, std::errc &error, + size_t &parsed_length) { + fast_float::from_chars_result const result = fast_float::from_chars( + input.data(), input.data() + input.size(), value); + error = result.ec; + parsed_length = size_t(result.ptr - input.data()); +} + +bool verify_case(input_case const &test) { + double input_value = 0; + double canonical_value = 0; + std::errc input_error; + std::errc canonical_error; + size_t input_length = 0; + size_t canonical_length = 0; + parse(test.input, input_value, input_error, input_length); + parse(test.canonical, canonical_value, canonical_error, canonical_length); + return takes_digit_comp(test.input) && + input_length + 1 == test.input.size() && + canonical_length + 1 == test.canonical.size() && + input_error == canonical_error && + bits(input_value) == bits(canonical_value); +} + +std::vector make_cases( + std::vector const &zero_counts, + std::vector const &core_lengths) { + // This prefix makes compute_float(m) and compute_float(m + 1) differ at + // exponent -18, so public from_chars reaches digit_comp after parsing a + // coefficient longer than 19 digits. + std::string const prefix = "6497987825129815764"; + std::vector result; + for (size_t core_length : core_lengths) { + std::string const tail = nonzero_tail(core_length - prefix.size()); + for (size_t zero_count : zero_counts) { + std::string const zeroes(zero_count, '0'); + std::string const integer_exponent = + std::to_string(-18 - int(tail.size()) - int(zero_count)); + + // Put a non-digit marker after each number so verification also checks + // the public from_chars pointer result. + result.push_back(input_case{prefix + tail + zeroes + "e" + + integer_exponent + "x", + prefix + tail + "e" + + std::to_string(-18 - int(tail.size())) + + "x"}); + result.push_back(input_case{"0." + prefix + tail + zeroes + "e1x", + "0." + prefix + tail + "e1x"}); + result.push_back(input_case{prefix.substr(0, 1) + "." + + prefix.substr(1) + tail + zeroes + "e0x", + prefix.substr(0, 1) + "." + + prefix.substr(1) + tail + "e0x"}); + } + } + return result; +} + +std::vector make_cases(std::vector const &zero_counts) { + return make_cases(zero_counts, {size_t(20), size_t(30), size_t(120)}); +} + +bool verify(std::vector const &cases) { + for (input_case const &test : cases) { + if (!verify_case(test)) { + return false; + } + } + return true; +} + +std::vector make_ordinary_cases() { + std::vector result; + result.reserve(1000); + for (size_t index = 0; index < 1000; ++index) { + int const integer = int(index % 97); + int const fraction = int((index * 17) % 100000); + int const exponent = int(index % 15) - 7; + std::string input; + switch (index % 4) { + case 0: + input = std::to_string(integer) + "." + std::to_string(fraction); + break; + case 1: + input = std::to_string(integer) + "." + std::to_string(fraction) + + "e" + std::to_string(exponent); + break; + case 2: + input = "-" + std::to_string(integer) + "." + + std::to_string(fraction) + "e" + std::to_string(exponent); + break; + default: + input = "0." + std::to_string(fraction) + "e" + + std::to_string(exponent); + break; + } + result.push_back(input_case{input + "x", std::string()}); + } + return result; +} + +bool verify_ordinary(std::vector const &cases) { + for (input_case const &test : cases) { + double value = 0; + std::errc error; + size_t parsed_length = 0; + parse(test.input, value, error, parsed_length); + if (error != std::errc() || parsed_length + 1 != test.input.size() || + takes_digit_comp(test.input)) { + return false; + } + } + return true; +} + +void parse_all(std::vector const &cases, size_t iterations) { + uint64_t local_sink = 0; + for (size_t iteration = 0; iteration < iterations; ++iteration) { + for (input_case const &test : cases) { + double value = 0; + std::errc error; + size_t parsed_length = 0; + parse(test.input, value, error, parsed_length); + local_sink += bits(value) + uint64_t(parsed_length) + uint64_t(error); + } + } + sink += local_sink; +} + +double benchmark(std::vector const &cases, size_t iterations) { + // Keep input construction and correctness validation out of the measured + // parse operation, as callers normally own the input buffers already. + parse_all(cases, 1); + std::chrono::steady_clock::time_point const start = + std::chrono::steady_clock::now(); + parse_all(cases, iterations); + std::chrono::steady_clock::duration const elapsed = + std::chrono::steady_clock::now() - start; + double const operations = double(cases.size()) * double(iterations); + return std::chrono::duration(elapsed).count() / + operations; +} + +double benchmark(std::vector const &cases) { + return benchmark(cases, 2000); +} + +} // namespace + +int main(int argc, char **argv) { + if (argc != 2) { + std::cerr << "usage: trailing_zero_benchmark " + "--verify|--boundary-benchmark|--pre-cutoff-benchmark|" + "--medium-suffix-benchmark|--ordinary-benchmark|--benchmark\n"; + return EXIT_FAILURE; + } + + std::string const mode(argv[1]); + if (mode == "--verify") { + std::vector const cases = make_cases( + {size_t(0), size_t(1), size_t(8), size_t(15), size_t(16), size_t(17), + size_t(32), size_t(63), size_t(64), size_t(65), size_t(700), + size_t(769), size_t(1000), size_t(4096)}, + {size_t(20), size_t(30), size_t(120), size_t(720), size_t(769)}); + if (!verify(cases)) { + std::cerr << "trailing-zero verification failed\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; + } + + if (mode == "--boundary-benchmark") { + std::vector zero_counts; + for (size_t zero_count = 0; zero_count <= 65; ++zero_count) { + zero_counts.push_back(zero_count); + } + std::vector const cases = make_cases( + zero_counts, {size_t(20), size_t(120), size_t(720), size_t(769)}); + if (!verify(cases)) { + std::cerr << "trailing-zero verification failed\n"; + return EXIT_FAILURE; + } + std::cout << "{\"metric\":\"ns_per_boundary_parse\",\"value\":" + << std::fixed << std::setprecision(3) << benchmark(cases, 1000) + << "}\n"; + return EXIT_SUCCESS; + } + + if (mode == "--pre-cutoff-benchmark") { + std::vector zero_counts; + for (size_t zero_count = 0; zero_count < 16; ++zero_count) { + zero_counts.push_back(zero_count); + } + std::vector const cases = make_cases( + zero_counts, + {size_t(20), size_t(30), size_t(120), size_t(720), size_t(769)}); + if (!verify(cases)) { + std::cerr << "trailing-zero verification failed\n"; + return EXIT_FAILURE; + } + std::cout << "{\"metric\":\"ns_per_pre_cutoff_parse\",\"value\":" + << std::fixed << std::setprecision(3) << benchmark(cases, 1000) + << "}\n"; + return EXIT_SUCCESS; + } + + if (mode == "--medium-suffix-benchmark") { + std::vector zero_counts; + for (size_t zero_count = 16; zero_count < 64; ++zero_count) { + zero_counts.push_back(zero_count); + } + std::vector const cases = make_cases( + zero_counts, {size_t(120), size_t(720), size_t(769)}); + if (!verify(cases)) { + std::cerr << "trailing-zero verification failed\n"; + return EXIT_FAILURE; + } + std::cout << "{\"metric\":\"ns_per_medium_suffix_parse\",\"value\":" + << std::fixed << std::setprecision(3) << benchmark(cases, 1000) + << "}\n"; + return EXIT_SUCCESS; + } + + if (mode == "--ordinary-benchmark") { + std::vector const cases = make_ordinary_cases(); + if (!verify_ordinary(cases)) { + std::cerr << "ordinary verification failed\n"; + return EXIT_FAILURE; + } + std::cout << "{\"metric\":\"ns_per_ordinary_parse\",\"value\":" + << std::fixed << std::setprecision(3) << benchmark(cases, 10000) + << "}\n"; + return EXIT_SUCCESS; + } + + if (mode == "--benchmark") { + std::vector const cases = make_cases( + {size_t(64), size_t(700), size_t(769), size_t(4096)}); + if (!verify(cases)) { + std::cerr << "trailing-zero verification failed\n"; + return EXIT_FAILURE; + } + std::cout << "{\"metric\":\"ns_per_parse\",\"value\":" + << std::fixed << std::setprecision(3) << benchmark(cases) + << "}\n"; + return EXIT_SUCCESS; + } + + std::cerr << "unknown mode: " << mode << '\n'; + return EXIT_FAILURE; +} diff --git a/include/fast_float/digit_comparison.h b/include/fast_float/digit_comparison.h index c2c83b0c..bcc2f834 100644 --- a/include/fast_float/digit_comparison.h +++ b/include/fast_float/digit_comparison.h @@ -449,6 +449,123 @@ digit_comp(parsed_number_string_t &num, adjusted_mantissa am) noexcept { } } +// A fixed-width confirmation is cheaper than rebuilding the corresponding +// bigint limbs once a coefficient has this many terminal zeroes. +constexpr size_t minimum_trailing_zeroes = 16; + +template +fastfloat_really_inline FASTFLOAT_CONSTEXPR20 bool +trim_at_least_zeros_from_end(UC const *first, UC const *&last, + size_t zeroes) noexcept { + if (size_t(last - first) < zeroes) { + return false; + } + while (!cpp20_and_in_constexpr() && + zeroes >= size_t(int_cmp_len())) { + uint64_t value; + last -= int_cmp_len(); + ::memcpy(&value, last, sizeof(value)); + if (value != int_cmp_zeros()) { + return false; + } + zeroes -= size_t(int_cmp_len()); + } + while (zeroes != 0) { + --last; + if (*last != UC('0')) { + return false; + } + --zeroes; + } + return true; +} + +template +fastfloat_really_inline FASTFLOAT_CONSTEXPR20 void +trim_zeros_from_end(UC const *first, UC const *&last) noexcept { + while (!cpp20_and_in_constexpr() && + std::distance(first, last) >= int_cmp_len()) { + uint64_t value; + ::memcpy(&value, last - int_cmp_len(), sizeof(value)); + if (value != int_cmp_zeros()) { + break; + } + last -= int_cmp_len(); + } + while (last != first && last[-1] == UC('0')) { + --last; + } +} + +template +fastfloat_really_inline FASTFLOAT_CONSTEXPR20 bool +ends_in_sixteen_zeroes(UC const *first, UC const *last) noexcept { + if (size_t(last - first) < minimum_trailing_zeroes) { + return false; + } + if (!cpp20_and_in_constexpr() && sizeof(UC) == 1) { + return ::memcmp(last - minimum_trailing_zeroes, + "0000000000000000", minimum_trailing_zeroes) == 0; + } + return trim_at_least_zeros_from_end(first, last, + minimum_trailing_zeroes); +} + +template +fastfloat_really_inline FASTFLOAT_CONSTEXPR20 bool +has_minimum_trailing_zeroes(parsed_number_string_t const &num) noexcept { + if (num.fraction.ptr == nullptr) { + return ends_in_sixteen_zeroes(num.integer.ptr, + num.integer.ptr + num.integer.len()); + } + UC const *fraction_end = num.fraction.ptr + num.fraction.len(); + if (num.fraction.len() >= minimum_trailing_zeroes) { + return ends_in_sixteen_zeroes(num.fraction.ptr, fraction_end); + } + if (!trim_at_least_zeros_from_end(num.fraction.ptr, fraction_end, + num.fraction.len())) { + return false; + } + UC const *integer_end = num.integer.ptr + num.integer.len(); + return trim_at_least_zeros_from_end( + num.integer.ptr, integer_end, + minimum_trailing_zeroes - num.fraction.len()); +} + +template +fastfloat_really_inline FASTFLOAT_CONSTEXPR20 bool +trim_trailing_zeros(parsed_number_string_t const &num, + parsed_number_string_t &trimmed) noexcept { + UC const *integer_end = num.integer.ptr + num.integer.len(); + UC const *fraction_end = num.fraction.ptr; + if (fraction_end != nullptr) { + fraction_end += num.fraction.len(); + trim_zeros_from_end(num.fraction.ptr, fraction_end); + } + if (fraction_end == nullptr || fraction_end == num.fraction.ptr) { + trim_zeros_from_end(num.integer.ptr, integer_end); + } + if (integer_end == num.integer.ptr && + (fraction_end == nullptr || fraction_end == num.fraction.ptr)) { + return false; + } + trimmed = num; + trimmed.integer = + span(num.integer.ptr, size_t(integer_end - num.integer.ptr)); + if (fraction_end != nullptr) { + trimmed.fraction = span( + num.fraction.ptr, size_t(fraction_end - num.fraction.ptr)); + } + return true; +} + +template +static fastfloat_noinline FASTFLOAT_CONSTEXPR20 bool +trim_confirmed_trailing_zeroes(parsed_number_string_t const &num, + parsed_number_string_t &trimmed) noexcept { + return trim_trailing_zeros(num, trimmed); +} + } // namespace fast_float #endif diff --git a/include/fast_float/float_common.h b/include/fast_float/float_common.h index de39fdd8..7d7ebad2 100644 --- a/include/fast_float/float_common.h +++ b/include/fast_float/float_common.h @@ -193,8 +193,13 @@ using parse_options = parse_options_t; #ifdef FASTFLOAT_VISUAL_STUDIO #define fastfloat_really_inline __forceinline -#else +#define fastfloat_noinline __declspec(noinline) +#elif defined(__GNUC__) || defined(__clang__) #define fastfloat_really_inline inline __attribute__((always_inline)) +#define fastfloat_noinline __attribute__((noinline)) +#else +#define fastfloat_really_inline inline +#define fastfloat_noinline #endif // Branch-probability hint marking the rare slow-path branches as cold, so the diff --git a/include/fast_float/parse_number.h b/include/fast_float/parse_number.h index c819c8a7..1cccc3fd 100644 --- a/include/fast_float/parse_number.h +++ b/include/fast_float/parse_number.h @@ -283,7 +283,16 @@ from_chars_advanced(parsed_number_string_t &pns, T &value) noexcept { // and we have an invalid power (am.power2 < 0), then we need to go the long // way around again. This is very uncommon. if (am.power2 < 0) { - am = digit_comp(pns, am); + if fastfloat_unlikely(has_minimum_trailing_zeroes(pns)) { + parsed_number_string_t trimmed; + if (trim_confirmed_trailing_zeroes(pns, trimmed)) { + am = digit_comp(trimmed, am); + } else { + am = digit_comp(pns, am); + } + } else { + am = digit_comp(pns, am); + } } to_float(pns.negative, am, value); // Test for over/underflow. @@ -295,14 +304,13 @@ from_chars_advanced(parsed_number_string_t &pns, T &value) noexcept { } // Slow path: re-parse materializing the integer/fraction spans the hot no-span -// parse skipped, then run the full algorithm. The two callers reach it only -// through a fastfloat_unlikely branch, so the optimizer keeps this re-parse off -// the hot path on its own (no function-level noinline needed). -// from_chars_advanced already handles both the too_many_digits disambiguation -// and the am.power2<0 digit_comp recompute, so both slow branches collapse to -// one helper call. +// parse skipped, then run the full algorithm. Keep it out of line so the rare +// reparse and suffix probe do not inflate the common parser. +// from_chars_advanced handles both the too_many_digits disambiguation and the +// am.power2<0 digit_comp recompute, so both slow branches collapse to one +// helper call. template -FASTFLOAT_CONSTEXPR20 from_chars_result_t +static fastfloat_noinline FASTFLOAT_CONSTEXPR20 from_chars_result_t parse_number_slow_path(UC const *first, UC const *last, T &value, parse_options_t options, bool bjf) noexcept { parsed_number_string_t pns = diff --git a/tests/BUILD.bazel b/tests/BUILD.bazel index 57d8c342..10c99de9 100644 --- a/tests/BUILD.bazel +++ b/tests/BUILD.bazel @@ -70,6 +70,15 @@ cc_test( ], ) +cc_test( + name = "trailing_zeros_test", + srcs = ["trailing_zeros_test.cpp"], + deps = [ + "//:fast_float", + "@doctest//doctest", + ], +) + cc_test( name = "powersoffive_hardround", srcs = ["powersoffive_hardround.cpp"], diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 19f24529..e3935b20 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -80,6 +80,7 @@ if (FASTFLOAT_SUPPLEMENTAL_TESTS) endif() fast_float_add_cpp_test(p2497) fast_float_add_cpp_test(long_test) +fast_float_add_cpp_test(trailing_zeros_test) fast_float_add_cpp_test(powersoffive_hardround) fast_float_add_cpp_test(string_test) fast_float_add_cpp_test(fast_int) diff --git a/tests/trailing_zeros_test.cpp b/tests/trailing_zeros_test.cpp new file mode 100644 index 00000000..1d9cfd17 --- /dev/null +++ b/tests/trailing_zeros_test.cpp @@ -0,0 +1,231 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include "doctest/doctest.h" + +#include "fast_float/fast_float.h" + +#include +#include +#include +#include +#include + +namespace { + +struct parse_result { + uint64_t value_bits; + size_t parsed_length; + std::errc error; +}; + +std::string nonzero_tail(size_t length) { + std::string result(length, '1'); + result.front() = '7'; + result.back() = '7'; + return result; +} + +uint64_t bits(double value) { + uint64_t result; + ::memcpy(&result, &value, sizeof(result)); + return result; +} + +parse_result parse(std::string const &input) { + double value = 0; + fast_float::from_chars_result const result = fast_float::from_chars( + input.data(), input.data() + input.size(), value); + return parse_result{bits(value), size_t(result.ptr - input.data()), result.ec}; +} + +fast_float::parsed_number_string parse_number(std::string const &input) { + fast_float::parse_options options; + return fast_float::parse_number_string( + input.data(), input.data() + input.size(), options, true); +} + +bool takes_digit_comp(std::string const &input) { + fast_float::parse_options options; + fast_float::parsed_number_string const parsed = + fast_float::parse_number_string( + input.data(), input.data() + input.size(), options, true); + fast_float::adjusted_mantissa am = + fast_float::compute_float>( + parsed.exponent, parsed.mantissa); + if (parsed.too_many_digits && am.power2 >= 0 && + am != fast_float::compute_float>( + parsed.exponent, parsed.mantissa + 1)) { + am = fast_float::compute_error>( + parsed.exponent, parsed.mantissa); + } + return parsed.valid && am.power2 < 0; +} + +void check_equivalent(std::string const &padded, std::string const &canonical) { + std::string const padded_with_marker = padded + "x"; + std::string const canonical_with_marker = canonical + "x"; + REQUIRE(takes_digit_comp(padded_with_marker)); + + parse_result const padded_result = parse(padded_with_marker); + parse_result const canonical_result = parse(canonical_with_marker); + CHECK(padded_result.parsed_length == padded.size()); + CHECK(canonical_result.parsed_length == canonical.size()); + CHECK(padded_result.error == canonical_result.error); + CHECK(padded_result.value_bits == canonical_result.value_bits); +} + +template std::basic_string widen(std::string const &input) { + return std::basic_string(input.begin(), input.end()); +} + +template parse_result parse_wide(std::string const &input) { + std::basic_string const wide = widen(input); + double value = 0; + fast_float::from_chars_result_t const result = fast_float::from_chars( + wide.data(), wide.data() + wide.size(), value); + return parse_result{bits(value), size_t(result.ptr - wide.data()), result.ec}; +} + +template +void check_equivalent_wide(std::string const &padded, + std::string const &canonical) { + std::string const padded_with_marker = padded + "x"; + std::string const canonical_with_marker = canonical + "x"; + parse_result const padded_result = parse_wide(padded_with_marker); + parse_result const canonical_result = parse_wide(canonical_with_marker); + CHECK(padded_result.parsed_length == padded.size()); + CHECK(canonical_result.parsed_length == canonical.size()); + CHECK(padded_result.error == canonical_result.error); + CHECK(padded_result.value_bits == canonical_result.value_bits); +} + +} // namespace + +TEST_CASE("long trailing zero coefficients preserve public from_chars results") { + // The prefix is an ambiguous 19-digit mantissa at exponent -18. Adding a + // twentieth digit makes public from_chars use the digit comparison fallback. + std::string const prefix = "6497987825129815764"; + std::vector const zero_counts = { + 0, 1, 8, 15, 16, 17, 32, 63, 64, 65, 700, 769, 1000, 4096}; + for (size_t core_length : + {size_t(20), size_t(30), size_t(120), size_t(720), size_t(769)}) { + std::string const tail = nonzero_tail(core_length - prefix.size()); + for (size_t zero_count : zero_counts) { + std::string const zeroes(zero_count, '0'); + std::string const integer_exponent = + std::to_string(-18 - int(tail.size()) - int(zero_count)); + + // Integer suffixes need a compensating explicit exponent. Fractional + // suffixes do not, because the decimal point already fixes their scale. + check_equivalent(prefix + tail + zeroes + "e" + integer_exponent, + prefix + tail + "e" + + std::to_string(-18 - int(tail.size()))); + check_equivalent("-" + prefix + tail + zeroes + "e" + integer_exponent, + "-" + prefix + tail + "e" + + std::to_string(-18 - int(tail.size()))); + check_equivalent("0." + prefix + tail + zeroes + "e1", + "0." + prefix + tail + "e1"); + check_equivalent(prefix.substr(0, 1) + "." + prefix.substr(1) + tail + + zeroes + "e0", + prefix.substr(0, 1) + "." + prefix.substr(1) + tail + + "e0"); + } + } +} + +TEST_CASE("trailing zero fallback accepts inputs without explicit exponents") { + // This prefix is ambiguous at exponent -19, which is the corrected exponent + // for a fraction-only input without an explicit exponent. + std::string const prefix = "2686910556586236953"; + for (size_t zero_count : + {size_t(0), size_t(15), size_t(16), size_t(63), size_t(64), + size_t(65), size_t(700), size_t(4096)}) { + std::string const zeroes(zero_count, '0'); + check_equivalent("0." + prefix + "7" + zeroes, + "0." + prefix + "7"); + } +} + +TEST_CASE("trailing zero fallback supports wide character input") { + std::string const prefix = "6497987825129815764"; + std::string const tail = nonzero_tail(101); + std::string const zeroes(769, '0'); + std::string const padded = "0." + prefix + tail + zeroes + "e1"; + std::string const canonical = "0." + prefix + tail + "e1"; + check_equivalent_wide(padded, canonical); + check_equivalent_wide(padded, canonical); + check_equivalent_wide(padded, canonical); +} + +TEST_CASE("all-zero coefficients retain their public result") { + for (size_t zero_count : {size_t(0), size_t(16), size_t(700), size_t(4096)}) { + std::string const integer = "0" + std::string(zero_count, '0') + "e10x"; + std::string const fraction = "0." + std::string(zero_count, '0') + "e-10x"; + parse_result const integer_result = parse(integer); + parse_result const fraction_result = parse(fraction); + CHECK(integer_result.parsed_length + 1 == integer.size()); + CHECK(fraction_result.parsed_length + 1 == fraction.size()); + CHECK(integer_result.error == std::errc()); + CHECK(fraction_result.error == std::errc()); + CHECK(integer_result.value_bits == 0); + CHECK(fraction_result.value_bits == 0); + } +} + +TEST_CASE("trailing zero normalization shortens only logical spans") { + std::string const zeroes64(64, '0'); + + std::string const fraction_input = "120.3" + zeroes64; + fast_float::parsed_number_string fraction = parse_number(fraction_input); + fast_float::parsed_number_string trimmed; + REQUIRE(fast_float::has_minimum_trailing_zeroes(fraction)); + REQUIRE(fast_float::trim_trailing_zeros(fraction, trimmed)); + CHECK(fraction.integer.len() == 3); + CHECK(fraction.fraction.len() == 65); + CHECK(trimmed.integer.len() == 3); + CHECK(trimmed.fraction.len() == 1); + CHECK(std::string(trimmed.integer.ptr, trimmed.integer.len()) == "120"); + CHECK(std::string(trimmed.fraction.ptr, trimmed.fraction.len()) == "3"); + + std::string const across_point_input = "120." + zeroes64; + fast_float::parsed_number_string across_point = + parse_number(across_point_input); + REQUIRE(fast_float::has_minimum_trailing_zeroes(across_point)); + REQUIRE(fast_float::trim_trailing_zeros(across_point, trimmed)); + CHECK(trimmed.integer.len() == 2); + CHECK(trimmed.fraction.len() == 0); + CHECK(std::string(trimmed.integer.ptr, trimmed.integer.len()) == "12"); + + std::string const integer_input = "123" + zeroes64; + fast_float::parsed_number_string integer_only = parse_number(integer_input); + REQUIRE(fast_float::has_minimum_trailing_zeroes(integer_only)); + REQUIRE(fast_float::trim_trailing_zeros(integer_only, trimmed)); + CHECK(trimmed.integer.len() == 3); + CHECK(trimmed.fraction.len() == 0); + CHECK(std::string(trimmed.integer.ptr, trimmed.integer.len()) == "123"); + + std::string const short_suffix_input = "120.3" + std::string(15, '0'); + fast_float::parsed_number_string short_suffix = + parse_number(short_suffix_input); + CHECK_FALSE(fast_float::has_minimum_trailing_zeroes(short_suffix)); + + std::string const cutoff_input = "120.3" + std::string(16, '0'); + fast_float::parsed_number_string cutoff = parse_number(cutoff_input); + REQUIRE(fast_float::has_minimum_trailing_zeroes(cutoff)); + REQUIRE(fast_float::trim_trailing_zeros(cutoff, trimmed)); + CHECK(trimmed.integer.len() == 3); + CHECK(trimmed.fraction.len() == 1); + + std::string const cross_span_input = + "123" + std::string(15, '0') + ".0"; + fast_float::parsed_number_string cross_span = parse_number(cross_span_input); + REQUIRE(fast_float::has_minimum_trailing_zeroes(cross_span)); + REQUIRE(fast_float::trim_trailing_zeros(cross_span, trimmed)); + CHECK(trimmed.integer.len() == 3); + CHECK(trimmed.fraction.len() == 0); + CHECK(std::string(trimmed.integer.ptr, trimmed.integer.len()) == "123"); + + std::string const all_zero_input = "0." + zeroes64; + fast_float::parsed_number_string all_zero = parse_number(all_zero_input); + REQUIRE(fast_float::has_minimum_trailing_zeroes(all_zero)); + CHECK_FALSE(fast_float::trim_trailing_zeros(all_zero, trimmed)); +}