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(),