FractionalFormat.java

// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

package org.apache.doris.common;

import com.fasterxml.jackson.core.io.schubfach.DoubleToDecimal;
import com.fasterxml.jackson.core.io.schubfach.FloatToDecimal;

/**
 * Converts floating-point values to the string representation used by Doris.
 *
 * <p>This is deliberately not just a wrapper around {@link Float#toString(float)} or
 * {@link Double#toString(double)}. FE constant folding and BE expression evaluation must produce
 * the same text. BE uses fmt's shortest-round-trip conversion and Doris keeps values whose decimal
 * exponent is in [-4, 16) in fixed notation. Java uses different notation boundaries and requires
 * at least two significant digits in a few cases.
 *
 * <p>The conversion is therefore split into two stages:
 * <ol>
 *   <li>Jackson's Schubfach implementation computes round-tripping decimal digits with bounded
 *       work and no former BigDecimal precision-search loop. The compatibility step below removes
 *       Java's required second digit when fmt can represent the value with one digit.</li>
 *   <li>{@link #format(String, long, boolean)} normalizes that decimal to the spelling used by BE:
 *       fixed/scientific notation boundaries, lower-case {@code e}, an explicit exponent sign,
 *       at least two exponent digits, and no redundant fractional zeroes.</li>
 * </ol>
 *
 * <p>NaN, infinities, and signed zero are handled before Schubfach because they have Doris-specific
 * spellings and signed zero must retain its raw sign bit.
 */
public class FractionalFormat {

    /**
     * Get the shortest string that round-trips to the given float value.
     *
     * @param value The float value.
     * @return string value.
     */
    public static String getFormatStringValue(float value) {
        if (Float.isNaN(value)) {
            return "NaN";
        }
        if (Float.isInfinite(value)) {
            return value > 0 ? "Infinity" : "-Infinity";
        }
        if (value == 0) {
            return Float.floatToRawIntBits(value) < 0 ? "-0" : "0";
        }
        // Keep the raw bits so format() can verify the only compatibility candidate that
        // Schubfach intentionally does not emit under Java's formatting rules.
        return format(FloatToDecimal.toString(value), Float.floatToRawIntBits(value), true);
    }

    /**
     * Get the shortest string that round-trips to the given double value.
     *
     * @param value The double value.
     * @return string value.
     */
    public static String getFormatStringValue(double value) {
        if (Double.isNaN(value)) {
            return "NaN";
        }
        if (Double.isInfinite(value)) {
            return value > 0 ? "Infinity" : "-Infinity";
        }
        if (value == 0) {
            return Double.doubleToRawLongBits(value) < 0 ? "-0" : "0";
        }
        // Keep the raw bits so format() can verify the only compatibility candidate that
        // Schubfach intentionally does not emit under Java's formatting rules.
        return format(DoubleToDecimal.toString(value), Double.doubleToRawLongBits(value), false);
    }

    /**
     * Rewrites a Schubfach result into the notation used by the BE formatter.
     *
     * <p>{@code digits} below is the significand with its decimal point and redundant trailing
     * zeroes removed. {@code exponent} is the decimal exponent of its first digit. Consequently,
     * the represented magnitude is:
     *
     * <pre>
     * integer(digits) * 10 ^ (exponent - digits.length() + 1)
     * </pre>
     *
     * <p>For example, both {@code 0.00123} and {@code 1.23E-3} become
     * {@code digits = "123"} and {@code exponent = -3}. Keeping this canonical pair makes the
     * final choice between fixed and scientific notation independent of the notation selected by
     * Jackson.
     *
     * @param value shortest decimal emitted by Jackson's Schubfach converter
     * @param rawBits original IEEE-754 bits, used to validate a possible one-digit representation
     * @param singlePrecision whether {@code rawBits} represents a float rather than a double
     * @return the equivalent decimal using Doris/BE formatting conventions
     */
    private static String format(String value, long rawBits, boolean singlePrecision) {
        // Schubfach emits either [-]d.ddd or [-]d.dddE[+-]dd. Locate the sign, significand,
        // decimal point, and optional exponent without constructing intermediate substrings.
        boolean negative = value.charAt(0) == '-';
        int mantissaStart = negative ? 1 : 0;
        int exponentMarker = value.indexOf('E', mantissaStart);
        int mantissaEnd = exponentMarker < 0 ? value.length() : exponentMarker;
        int decimalPoint = value.indexOf('.', mantissaStart);

        // Plain small values can start with "0.00...". Skip both those leading zeroes and the
        // decimal point so that digits always starts at the first non-zero significant digit.
        int firstSignificantDigit = mantissaStart;
        while (value.charAt(firstSignificantDigit) == '0'
                || value.charAt(firstSignificantDigit) == '.') {
            firstSignificantDigit++;
        }

        // Scientific input already carries the exponent of its first digit. For plain input,
        // derive the same exponent from the positions of the first digit and decimal point.
        int exponent = exponentMarker < 0
                ? decimalExponent(firstSignificantDigit, decimalPoint)
                : parseExponent(value, exponentMarker + 1);

        // Copy only significant digits. Removing trailing zeroes is safe because exponent denotes
        // the position of the first digit, so it changes neither the magnitude nor decimal point.
        StringBuilder digits = new StringBuilder(17);
        for (int i = firstSignificantDigit; i < mantissaEnd; i++) {
            char c = value.charAt(i);
            if (c != '.') {
                digits.append(c);
            }
        }
        while (digits.length() > 1 && digits.charAt(digits.length() - 1) == '0') {
            digits.setLength(digits.length() - 1);
        }
        exponent = shortenToOneDigit(digits, exponent, negative, rawBits, singlePrecision);

        // Match the BE fmt policy: decimal exponents -4 through 15 use fixed notation; all other
        // exponents use scientific notation. Examples are 0.0001, 1e-05, 10^15, and 1e+16.
        StringBuilder result = new StringBuilder(24);
        if (negative) {
            result.append('-');
        }
        if (exponent >= -4 && exponent < 16) {
            appendPlainString(result, digits, exponent);
        } else {
            appendScientificString(result, digits, exponent);
        }
        return result.toString();
    }

    private static int shortenToOneDigit(StringBuilder digits, int exponent, boolean negative,
            long rawBits, boolean singlePrecision) {
        /*
         * Jackson's Schubfach implementation follows Java's rule that a finite non-zero result has
         * at least two significant digits. fmt instead minimizes the digit count whenever the
         * shorter decimal still maps to the same binary value. For example:
         *
         *   Jackson: Double.MIN_VALUE -> 4.9E-324
         *   BE fmt:  Double.MIN_VALUE -> 5e-324
         *
         * A result with three or more digits cannot be affected by this minimum-two-digits rule,
         * and a one-digit result is already minimal. Therefore only a two-digit significand needs
         * one compatibility check.
         *
         * First round the two-digit decimal to its nearest one-digit decimal using ties-to-even,
         * which is the IEEE-754 decimal selection rule. Rounding 9x may carry into the exponent
         * (for example, 9.9e-324 becomes 1e-323). The candidate is accepted only when parsing it
         * reproduces the original raw bits. Decimal equality is intentionally insufficient here:
         * distinct decimal numbers are expected to map to the same float or double inside a
         * rounding interval.
         *
         * This performs at most one parse and replaces the former loop that built and parsed up to
         * 16 BigDecimal candidates for every double cell.
         */
        if (digits.length() != 2) {
            return exponent;
        }

        // HALF_EVEN rounding from two significant digits to one significant digit.
        int firstDigit = digits.charAt(0) - '0';
        int secondDigit = digits.charAt(1) - '0';
        if (secondDigit > 5 || (secondDigit == 5 && (firstDigit & 1) != 0)) {
            firstDigit++;
        }
        int candidateExponent = exponent;
        // Normalize the carry from 10 * 10^exponent to 1 * 10^(exponent + 1).
        if (firstDigit == 10) {
            firstDigit = 1;
            candidateExponent++;
        }

        // Scientific notation avoids having to insert a variable number of zeroes. Java's parser
        // accepts an unpadded exponent, so this temporary string is only for the identity check.
        StringBuilder candidate = new StringBuilder(8);
        if (negative) {
            candidate.append('-');
        }
        candidate.append(firstDigit).append('e').append(candidateExponent);
        boolean roundTrips = singlePrecision
                ? Float.floatToRawIntBits(Float.parseFloat(candidate.toString())) == (int) rawBits
                : Double.doubleToRawLongBits(Double.parseDouble(candidate.toString())) == rawBits;
        if (roundTrips) {
            digits.setLength(1);
            digits.setCharAt(0, (char) ('0' + firstDigit));
            return candidateExponent;
        }
        return exponent;
    }

    /**
     * Returns the exponent of the first significant digit in a plain decimal.
     *
     * <p>When that digit is before the decimal point, the exponent is the number of following
     * integral positions. When it is after the decimal point, the negative distance from the
     * decimal point gives the exponent. For example, {@code 123.4} returns 2 and {@code 0.001234}
     * returns -3.
     */
    private static int decimalExponent(int firstSignificantDigit, int decimalPoint) {
        if (firstSignificantDigit < decimalPoint) {
            return decimalPoint - firstSignificantDigit - 1;
        }
        return decimalPoint - firstSignificantDigit;
    }

    /**
     * Parses the signed exponent of a Schubfach scientific string without allocating a substring.
     */
    private static int parseExponent(String value, int offset) {
        boolean negative = value.charAt(offset) == '-';
        if (negative || value.charAt(offset) == '+') {
            offset++;
        }
        int exponent = 0;
        for (int i = offset; i < value.length(); i++) {
            exponent = exponent * 10 + value.charAt(i) - '0';
        }
        return negative ? -exponent : exponent;
    }

    /**
     * Appends fixed notation from canonical significant digits and their first-digit exponent.
     *
     * <p>The three branches place the decimal point before the digits ({@code 0.00123}), after the
     * digits with appended zeroes ({@code 12300}), or inside the digits ({@code 123.45}).
     */
    private static void appendPlainString(StringBuilder result, StringBuilder digits, int exponent) {
        if (exponent < 0) {
            result.append("0.");
            for (int i = -1; i > exponent; i--) {
                result.append('0');
            }
            result.append(digits);
            return;
        }

        int integralDigits = exponent + 1;
        if (digits.length() <= integralDigits) {
            result.append(digits);
            for (int i = digits.length(); i < integralDigits; i++) {
                result.append('0');
            }
            return;
        }

        result.append(digits, 0, integralDigits)
                .append('.')
                .append(digits, integralDigits, digits.length());
    }

    /**
     * Appends normalized scientific notation such as {@code 1.23e-08} or {@code 1e+16}.
     *
     * <p>Doris follows BE fmt spelling: lower-case {@code e}, an explicit sign for positive
     * exponents, and a leading zero for one-digit exponents.
     */
    private static void appendScientificString(StringBuilder result, StringBuilder digits,
            int exponent) {
        result.append(digits.charAt(0));
        if (digits.length() > 1) {
            result.append('.').append(digits, 1, digits.length());
        }
        result.append(exponent < 0 ? "e-" : "e+");
        int absoluteExponent = Math.abs(exponent);
        if (absoluteExponent < 10) {
            result.append('0');
        }
        result.append(absoluteExponent);
    }
}