diff --git a/CHANGELOG.md b/CHANGELOG.md index 8032a541..68e82ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,30 @@ All notable changes to EigenScript are documented here. ### Changed +- **JSON is a lossless round-trip for every number (#875).** The + contract promises `num of (str of x) == x`. That held for `str of` and + for nothing else: `json_encode`, `json_build` and `json_path` each + carried their own `%.15g` — one digit short of the 17 a double can + need — so a value written as JSON and read back was a **different + number**, silently, in the primary serialization format + (`3.141592653589793` → `3.14159265358979`). Each also had its own + integer fast path with its own bound (2^31, 1e9, 1e9), so every + integer between it and 2^53 went through `%.15g` too: an ID of + `1234567890123456` encoded as `1.23456789012346e+15` and decoded as + `1234567890123460`. There were **four** hand-copies of the + number→text rule (the fourth in the SIGUSR1 observer dump); there is + now one, `eigs_num_text`, and `str of` calls it as well — so + `json_encode of x == str of x` for every number and a fifth copy + cannot appear with a fifth rule. One output change falls out of the + unification: an exact integer below 2^53 renders bare rather than in + exponent form, so `json_build of ["v", 1.23e15]` is now + `{"v": 1230000000000000}` — 16 characters, matching `str of`, and + still nothing like the 22-char fixed-point blob #725 removed. + `tests/test_json_roundtrip.eigs` pins all three encoders against + `str of` over the hard doubles and the exact-integer band, validated + with a planted fault in each half (the precision escalation and the + integer bound are independently load-bearing). + - **chart renders 1.5× faster at high point counts (#828).** The series hot loop called `_chart_map` — a fresh 2-element list — per plotted point per frame; at 4,000 points that allocation was ~37% of the frame diff --git a/docs/LANGUAGE_CONTRACT.md b/docs/LANGUAGE_CONTRACT.md index e8bd07ec..8069505d 100644 --- a/docs/LANGUAGE_CONTRACT.md +++ b/docs/LANGUAGE_CONTRACT.md @@ -107,10 +107,15 @@ examples (executed by the suite). yields 0. - `str of` produces the shortest representation that round-trips back to the same double; `num of (str of x) == x`. +- **Every producer of number text obeys that same rule** — `str of`, + `json_encode`, `json_build`, `json_path`, and the SIGUSR1 observer dump + all share one implementation (`eigs_num_text`), so a JSON round-trip + returns the same double and `json_encode of x` equals `str of x` for + every number (#875). - `%` follows the dividend's sign (C semantics): `-7 % 3 == -1`. **Status:** Enforced — `tests/test_number_format.eigs`, -`tests/test_numeric_guard.eigs`. +`tests/test_numeric_guard.eigs`, `tests/test_json_roundtrip.eigs`. ## Strings diff --git a/src/builtins.c b/src/builtins.c index 45840fee..4917062c 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -822,17 +822,14 @@ static int eigs_json_encode_value(Value *v, strbuf *out, int depth) { return -1; switch (v->type) { case VAL_NUM: { - double n = v->data.num; - /* #816: magnitude BEFORE the narrowing cast (same class as - * #695) — converting a double beyond int's range is UB, and - * the old `n == (int)n && fabs(n) < 1e15` order ran the cast - * first. The bound is int's own range: integral values beyond - * it never took the %d path anyway (the equality failed), so - * output is unchanged and the cast is now always defined. */ - if (fabs(n) < 2147483648.0 && n == (int)n) - strbuf_append_fmt(out, "%d", (int)n); - else - strbuf_append_fmt(out, "%.15g", n); + /* #875: one shared rule with `str of` — exact integers to 2^53 + * bare, otherwise the shortest round-tripping form. The local + * %d fast path stopped at 2^31 and handed everything above it + * to %.15g, so integer IDs between 2^31 and 2^53 came back as a + * different number. */ + char nb[32]; + eigs_num_text(nb, sizeof(nb), v->data.num); + strbuf_append(out, nb); break; } case VAL_STR: { @@ -1350,14 +1347,9 @@ Value* builtin_json_build(Value *arg) { strbuf_append_n(&out, ": ", 2); Value *val = arg->data.list.items[i + 1]; if (val->type == VAL_NUM) { - double d = val->data.num; - /* #816: range BEFORE the cast — same class as the other two - * encoder sites; this variant shape was caught by the new - * float-cast-overflow gate in CI, not by pattern-grep. */ - if (d >= -1e9 && d <= 1e9 && d == (double)(int)d) - strbuf_append_fmt(&out, "%d", (int)d); - else - strbuf_append_fmt(&out, "%.15g", d); + char nb[32]; /* #875: the shared rule */ + eigs_num_text(nb, sizeof(nb), val->data.num); + strbuf_append(&out, nb); } else if (val->type == VAL_NULL) { strbuf_append(&out, "null"); } else if (val->type == VAL_JSON_RAW) { @@ -2231,12 +2223,7 @@ Value* builtin_json_path(Value *arg) { } if (current->type == VAL_NUM) { char buf[64]; - double d = current->data.num; - /* #816: range before the cast (see json_build above). */ - if (fabs(d) < 1e9 && d == (double)(int)d) - snprintf(buf, sizeof(buf), "%d", (int)d); - else - snprintf(buf, sizeof(buf), "%.15g", d); + eigs_num_text(buf, sizeof(buf), current->data.num); /* #875 */ val_decref(root); return make_str(buf); } diff --git a/src/eigenscript.c b/src/eigenscript.c index 28eb9efc..8e5b478b 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -1841,6 +1841,38 @@ static int values_equal_impl(Value *a, Value *b, int depth) { int values_equal(Value *a, Value *b) { return values_equal_impl(a, b, 0); } +/* THE number->text rule, in one place (#875). + * + * LANGUAGE_CONTRACT.md:108 promises `num of (str of x) == x`. That held for + * `str of` and for nothing else: the three JSON encoders each carried their + * own `%.15g`, one digit short of the 17 a double needs, so a value written + * as JSON and read back was a DIFFERENT number — silently, in the primary + * serialization format. They also each had their own integer fast path with + * a different bound (2^31, 1e9, 1e9), so every integer between that bound + * and 2^53 went through `%.15g` too: 1234567890123456 encoded as + * 1.23456789012346e+15 and decoded as 1234567890123460. + * + * Every producer of number text now calls this, so a fourth copy cannot + * appear with a fourth rule. Writes at most 32 bytes. */ +void eigs_num_text(char *buf, size_t nbuf, double n) { + /* Exact integers up to 2^53 (the largest integer all doubles represent + * exactly) print without a decimal point or exponent. The magnitude test + * runs BEFORE the cast — casting an out-of-range double is UB (#816). */ + if (fabs(n) < 9007199254740992.0 && n == (long long)n) { + snprintf(buf, nbuf, "%lld", (long long)n); + return; + } + /* Otherwise the shortest representation that round-trips: try 15..17 + * significant digits and stop at the first that parses back to the same + * double. %.6g (the old `str of` default) silently truncated every float + * to 6 figures — lossy for the numerical/STEM workloads this language + * targets — and %.15g loses the 17th digit a double can need. */ + for (int prec = 15; prec <= 17; prec++) { + snprintf(buf, nbuf, "%.*g", prec, n); + if (strtod(buf, NULL) == n) return; + } +} + char* value_to_string(Value *v) { if (!v) return xstrdup("null"); if (g_vts_depth > 64) return xstrdup("[...]"); @@ -1848,22 +1880,7 @@ char* value_to_string(Value *v) { switch (v->type) { case VAL_NULL: return xstrdup("null"); case VAL_NUM: { - double n = v->data.num; - /* Exact integers up to 2^53 (the largest integer all doubles - * represent exactly) print without a decimal point or exponent. */ - if (fabs(n) < 9007199254740992.0 && n == (long long)n) { - snprintf(buf, sizeof(buf), "%lld", (long long)n); - } else { - /* Shortest representation that round-trips: try 15..17 - * significant digits and stop at the first that parses back - * to the same double. %.6g (the old default) silently - * truncated every float to 6 figures — lossy for the - * numerical/STEM workloads this language targets. */ - for (int prec = 15; prec <= 17; prec++) { - snprintf(buf, sizeof(buf), "%.*g", prec, n); - if (strtod(buf, NULL) == n) break; - } - } + eigs_num_text(buf, sizeof(buf), v->data.num); return xstrdup(buf); } case VAL_STR: return xstrdup(v->data.str); diff --git a/src/eigenscript.h b/src/eigenscript.h index 3af4da39..4c223d9b 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -1172,6 +1172,10 @@ int is_truthy(Value *v); * identity for functions/builtins; no cross-type coercion). */ int values_equal(Value *a, Value *b); char* value_to_string(Value *v); +/* #875: THE number->text rule. Every producer of number text calls this — + * `str of`, all three JSON encoders, the SIGUSR1 observer dump — so a copy + * with a different precision cannot reappear. Needs 32 bytes. */ +void eigs_num_text(char *buf, size_t nbuf, double n); void observer_ensure_fresh(Value *v); void eigs_json_escape_string(strbuf *out, const char *s); diff --git a/src/vm.c b/src/vm.c index a92d0b85..54933fce 100644 --- a/src/vm.c +++ b/src/vm.c @@ -73,20 +73,10 @@ void eigs_sigusr1_handler(int sig) { g_eigs_sigusr1_pending = 1; } -/* Number formatting mirrors value_to_string's VAL_NUM case: exact integers - * print bare, anything else shortest round-trip %.15g..%.17g. The range - * check runs BEFORE the long long cast — casting an out-of-range double is - * UB and this path sees arbitrary user values under UBSan. */ +/* #875: the shared number->text rule (eigs_num_text) — this was a fourth + * hand-copy of it. Allocation-free, as this path requires. */ static void obs_dump_num(double n, char *buf, size_t nbuf) { - if (fabs(n) < 9007199254740992.0 && n == (long long)n) { - snprintf(buf, nbuf, "%lld", (long long)n); - } else { - for (int prec = 15; prec <= 17; prec++) { - snprintf(buf, nbuf, "%.*g", prec, n); - double back = strtod(buf, NULL); - if (memcmp(&back, &n, sizeof back) == 0) break; - } - } + eigs_num_text(buf, nbuf, n); } /* Compact, bounded rendering of a slot's value: numbers inline, strings diff --git a/tests/test_json_hard.eigs b/tests/test_json_hard.eigs index e3742575..e9bf5db8 100644 --- a/tests/test_json_hard.eigs +++ b/tests/test_json_hard.eigs @@ -410,7 +410,15 @@ assert of [jb_tiny == "{\"v\": 1e-09}", "JH94 json_build keeps tiny magnitude"] rt_enc is json_encode of {"k": 0.000000001} assert of [rt_enc == "{\"k\":1e-09}", "JH95 json_encode writes tiny magnitude"] assert of [json_path of [rt_enc, "k"] == "1e-09", "JH96 json_path round-trips what json_encode wrote"] +# #875: 1.23e15 is an exact integer below 2^53, so it now renders the way +# `str of` renders it — bare, not in exponent form. The two used to disagree, +# which is the drift this fix removed; the compactness #725 was protecting +# (no 22-char fixed-point blob) still holds at 16 characters. jb_huge is json_build of ["v", 1.23e15] -assert of [jb_huge == "{\"v\": 1.23e+15}", "JH97 json_build renders huge values compactly"] +assert of [jb_huge == "{\"v\": 1230000000000000}", "JH97 json_build renders huge values compactly"] +assert of [jb_huge == ("{\"v\": " + (str of 1.23e15) + "}"), "JH97 json_build agrees with str of"] +# A magnitude that is NOT an exact integer still uses exponent form. +jb_huge2 is json_build of ["v", 1.23e300] +assert of [jb_huge2 == "{\"v\": 1.23e+300}", "JH97 json_build keeps exponent form past 2^53"] print of "json hard: all passed" diff --git a/tests/test_json_roundtrip.eigs b/tests/test_json_roundtrip.eigs index 061202a9..bed480d6 100644 --- a/tests/test_json_roundtrip.eigs +++ b/tests/test_json_roundtrip.eigs @@ -40,4 +40,47 @@ assert of [(json_encode of 1e300) == "1e+300", "1e300 encodes, cast never runs ( rtb is json_decode of json_encode of {"n": 1e300} assert of [rtb.n == 1e300, "1e300 round-trips through encode/decode (#816)"] + +# ---- #875: JSON is a LOSSLESS round-trip for every double ---- +# LANGUAGE_CONTRACT.md:108 promises `num of (str of x) == x`. The three JSON +# encoders each carried their own %.15g — one digit short of the 17 a double +# needs — so a value written as JSON and read back was a different number. +# Every encoder now shares `str of`'s rule, so all three must agree with it +# AND with themselves. +hard is [3.141592653589793, 0.1, 0.30000000000000004, 2.718281828459045, + 1.7976931348623157e308, 5e-324, 1.0000000000000002, + 0 - 3.141592653589793, 1e-7, 123456789.123456789] +for v in hard: + enc is json_encode of v + assert of [(json_decode of enc) == v, "encode round-trips exactly"] + assert of [enc == (str of v), "json_encode agrees with str of"] + b is json_decode of (json_build of [["v", v]]) + assert of [b.v == v, "json_build round-trips exactly"] + doc is json_encode of {"v": v} + assert of [(num of (json_path of [doc, "v"])) == v, "json_path round-trips exactly"] + +# The named case from the issue. +pi is 3.141592653589793 +assert of [(json_encode of pi) == "3.141592653589793", "pi keeps all 16 digits"] +assert of [(json_decode of (json_encode of pi)) == pi, "pi survives the round-trip"] + +# Integers between the old %d fast path (2^31) and 2^53 went through %.15g +# too, so an integer ID came back as a DIFFERENT integer: 1234567890123456 +# encoded as 1.23456789012346e+15 and decoded as 1234567890123460. +# Round magnitudes are the ones that need the exact-integer fast path: the +# %.15g..%.17g escalation is happy to print 1e15 for 1000000000000000 (it +# round-trips), so only the integer branch keeps an exact integer looking +# like one. The ragged values below cover the escalation itself. +big_ints is [1234567890123456, 9007199254740992, 9007199254740991, + 4503599627370496, 0 - 9007199254740992, + 1000000000000000, 100000000000000000 / 100, 4000000000] +for n in big_ints: + enc is json_encode of n + assert of [(contains of [enc, "e+"]) == 0, "an exact integer never encodes in exponent form"] + assert of [(json_decode of enc) == n, "an exact integer round-trips"] + assert of [enc == (str of n), "integer encoding agrees with str of"] + +assert of [(json_encode of 1234567890123456) == "1234567890123456", "16-digit integer is exact"] +assert of [(json_build of [["id", 1234567890123456]]) == "{\"id\": 1234567890123456}", "json_build keeps integer IDs exact"] + print of "json roundtrip: all passed"