Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/LANGUAGE_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 12 additions & 25 deletions src/builtins.c
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down
49 changes: 33 additions & 16 deletions src/eigenscript.c
Original file line number Diff line number Diff line change
Expand Up @@ -1841,29 +1841,46 @@

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("[...]");
char buf[256];
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);
Expand Down
4 changes: 4 additions & 0 deletions src/eigenscript.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
16 changes: 3 additions & 13 deletions src/vm.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion tests/test_json_hard.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
43 changes: 43 additions & 0 deletions tests/test_json_roundtrip.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +46 to +47
# 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"
Loading