Commit 77390baa1 for llama.cpp

commit 77390baa1872e3afe4bafa3dec8ca4bcac986e41
Author: Yuri Khrustalev <ykhrustalev@users.noreply.github.com>
Date:   Wed Oct 14 02:18:38 2026 -0400

    jinja : write floats in tojson as Python does (#30362)

    tojson printed floats through a C++ stream (6 significant digits, no
    ".0"), so 48.858844 became 48.8588 and 1.0 became 1 in tool definitions,
    tool-call arguments and other template output. Write the shortest digits
    that read back as the same value, as json.dumps does. The string and safe
    filters of a float use the same code.

diff --git a/common/jinja/value.cpp b/common/jinja/value.cpp
index 6edd8b96f..aed834ce4 100644
--- a/common/jinja/value.cpp
+++ b/common/jinja/value.cpp
@@ -8,6 +8,11 @@
 #include <sstream>
 #include <string>
 #include <cctype>
+#include <cfloat>
+#include <cmath>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
 #include <vector>
 #include <optional>
 #include <algorithm>
@@ -1517,6 +1522,65 @@ void global_from_json(context & ctx, const common_json & json_obj, bool mark_inp
     }
 }

+// a float as Python writes it (repr, json.dumps): the shortest digits that read back as the same value
+static std::string float_to_json(double val) {
+    if (!std::isfinite(val)) {
+        return std::isnan(val) ? "nan" : val < 0 ? "-inf" : "inf"; // as before (json.dumps writes NaN and Infinity, str() writes nan and inf)
+    }
+    const double mag = std::fabs(val);
+
+    // at a power of 2 the gap to the value below is half the gap above, the nearest digits can miss while the next ones up read back
+    int exp2 = 0;
+    const bool is_pow2 = std::frexp(mag, &exp2) == 0.5;
+
+    // a normal double has at most one decimal of up to DBL_DIG digits that reads back as it, the search can start at DBL_DIG digits
+    // 17 digits always read back
+    std::string digits;
+    int exp10 = 0;
+    for (int prec = mag < DBL_MIN ? 0 : DBL_DIG - 1; digits.empty(); prec++) {
+        char buf[32];
+        snprintf(buf, sizeof(buf), "%.*e", prec, mag);
+        const char * e = std::strchr(buf, 'e');
+        // only the digits, the decimal point depends on the locale
+        std::string d(buf, e - buf);
+        d.erase(std::remove_if(d.begin(), d.end(), [](char c) { return c < '0' || c > '9'; }), d.end());
+        exp10 = std::atoi(e + 1);
+        const auto reads_back = [&](const std::string & cand) {
+            return std::strtod((cand + "e" + std::to_string(exp10 - prec)).c_str(), nullptr) == mag;
+        };
+        if (prec == 16 || reads_back(d)) {
+            digits = d;
+        } else if (is_pow2 && d.back() != '9') {
+            d.back()++;
+            if (reads_back(d)) {
+                digits = d;
+            }
+        }
+    }
+    while (digits.size() > 1 && digits.back() == '0') {
+        digits.pop_back();
+    }
+
+    // fixed notation for 1e-4 <= |val| < 1e16, else d.ddde+XX
+    const int n     = static_cast<int>(digits.size());
+    const int point = exp10 + 1; // number of digits before the decimal point
+    std::string out = std::signbit(val) ? "-" : "";
+    if (point > -4 && point <= 16) {
+        if (point <= 0) {
+            out += "0." + std::string(-point, '0') + digits;
+        } else if (point >= n) {
+            out += digits + std::string(point - n, '0') + ".0";
+        } else {
+            out += digits.substr(0, point) + "." + digits.substr(point);
+        }
+    } else {
+        char buf[16];
+        snprintf(buf, sizeof(buf), "e%c%02d", exp10 < 0 ? '-' : '+', std::abs(exp10));
+        out += digits.substr(0, 1) + (n > 1 ? "." + digits.substr(1) : "") + buf;
+    }
+    return out;
+}
+
 // recursively convert value to JSON string
 // TODO: avoid circular references
 static void value_to_json_internal(std::ostringstream & oss, const value & val, int curr_lvl, int indent, const std::string_view item_sep, const std::string_view key_sep) {
@@ -1534,7 +1598,7 @@ static void value_to_json_internal(std::ostringstream & oss, const value & val,
     } else if (is_val<value_int>(val)) {
         oss << val->as_int();
     } else if (is_val<value_float>(val)) {
-        oss << val->as_float();
+        oss << float_to_json(val->as_float());
     } else if (is_val<value_string>(val)) {
         oss << "\"";
         for (char c : val->as_string().str()) {
diff --git a/tests/test-jinja.cpp b/tests/test-jinja.cpp
index 7649e3624..572b30816 100644
--- a/tests/test-jinja.cpp
+++ b/tests/test-jinja.cpp
@@ -1026,6 +1026,31 @@ static void test_literals(testing & t) {
         "3.14"
     );

+    // Python writes the shortest digits that read back as the same value
+    test_template(t, "tojson floats",
+        "{{ data|tojson }}",
+        {{"data", json::array({0.1, 1.0, 100.0, 8.3, 123456789.0, 48.858844, 0.30000000000000004, -0.0, 5.960464477539063e-08})}},
+        "[0.1, 1.0, 100.0, 8.3, 123456789.0, 48.858844, 0.30000000000000004, -0.0, 5.960464477539063e-08]"
+    );
+
+    test_template(t, "tojson floats, exponent",
+        "{{ data|tojson }}",
+        {{"data", json::array({1e15, 1e16, 0.0001, 0.00001, 1.5e10, -2.5e-7, 5e-324, 1.7976931348623157e308})}},
+        "[1000000000000000.0, 1e+16, 0.0001, 1e-05, 15000000000.0, -2.5e-07, 5e-324, 1.7976931348623157e+308]"
+    );
+
+    test_template(t, "tojson floats, nested with indent=2",
+        "{{ data|tojson(indent=2) }}",
+        {{"data", {{"total", 20.0}, {"items", json::array({{{"price", 9.99}, {"qty", 2}}})}}}},
+        "{\n  \"total\": 20.0,\n  \"items\": [\n    {\n      \"price\": 9.99,\n      \"qty\": 2\n    }\n  ]\n}"
+    );
+
+    test_template(t, "float|string",
+        "{{ a|string }} {{ b|string }} {{ (b * 10)|string }}",
+        {{"a", 1.0}, {"b", 1e308}},
+        "1.0 1e+308 inf"
+    );
+
     test_template(t, "string|tojson",
         "{{ 'hello' | tojson }}",
         json::object(),