From f345e424fb46822e831b6500944d15954419e705 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:57:31 +0000 Subject: [PATCH] IEEE-754 binary64 text-conversion prerequisite for Phase 4 of #863 Co-authored-by: paul-hammant <82182+paul-hammant@users.noreply.github.com> --- CHANGELOG.md | 10 ++ docs/stdlib-api.md | 1 + docs/stdlib-reference.md | 1 + std/string/aether_string.c | 54 ++++++++- std/string/aether_string.h | 4 + std/string/module.ae | 2 + .../test_string_double_roundtrip.ae | 114 ++++++++++++++++++ 7 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 tests/regression/test_string_double_roundtrip.ae diff --git a/CHANGELOG.md b/CHANGELOG.md index af295e05..7b6f7eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `main`, the release pipeline automatically replaces `[current]` with the next version number before tagging the release. +## [current] + +### Added + +- **Lossless binary64 text-conversion prerequisite** (#863 Phase 3.5). Implemented `string.from_double(value: float) -> string` in `std.string`. This function converts every finite IEEE-754 binary64 value to a lossless decimal representation (using `"%.17g"` format), ensuring exact bit equality after parsing back with `string.to_double()`. It handles positive/negative normal and subnormal values, signed zeros, NaN, and positive/negative infinity cleanly and locale-independently. + +### Fixed + +- **`string.to_float` / `string.to_double` rejected subnormal values.** `strtof` and `strtod` may set `ERANGE` for a representable underflow result, but the wrappers treated every `ERANGE` as failure. They now reject range overflow while accepting correctly rounded subnormal and zero results. + ## [0.491.0] ### Fixed diff --git a/docs/stdlib-api.md b/docs/stdlib-api.md index d6e51a8e..465494e0 100644 --- a/docs/stdlib-api.md +++ b/docs/stdlib-api.md @@ -140,6 +140,7 @@ typedef struct AetherString { - `string.to_cstr(str)` - Get C string pointer - `string.from_int(value)` - Convert int to string - `string.from_float(value)` - Convert float to string +- `string.from_double(value)` - Convert float (binary64) to lossless decimal string (round-trip safe) #### Parsing (Go-style) diff --git a/docs/stdlib-reference.md b/docs/stdlib-reference.md index 6c861bdd..c2829944 100644 --- a/docs/stdlib-reference.md +++ b/docs/stdlib-reference.md @@ -618,6 +618,7 @@ For a `split_once`-style operation (find the first `sep` in `s`, return the halv - `string.to_cstr(str)` - Get raw C string pointer - `string.from_int(value)` - Create string from integer - `string.from_float(value)` - Create string from float +- `string.from_double(value)` - Create lossless, round-trip-safe decimal string from float (binary64) **Parsing (Go-style):** - `string.to_int(s)` → `(int, string)` - Parse base-10 integer diff --git a/std/string/aether_string.c b/std/string/aether_string.c index 8cf3b724..513ae172 100644 --- a/std/string/aether_string.c +++ b/std/string/aether_string.c @@ -8,6 +8,8 @@ #include #include #include // SIZE_MAX (not in on MinGW) +#include +#include #ifndef _WIN32 #include // POSIX glob-pattern matching (string_glob_match_raw) @@ -813,6 +815,54 @@ AetherString* string_from_float(double value) { return string_new(buffer); } +// Deterministic double-to-decimal text conversion paired with +// string_to_double_raw. Seventeen significant decimal digits are sufficient +// to recover every finite IEEE-754 binary64 value exactly. This is deliberately +// round-trip-safe rather than shortest-round-trip formatting. +AetherString* string_from_double(double value) { + if (isnan(value)) { + return string_new("NaN"); + } + if (isinf(value)) { + if (value < 0.0) { + return string_new("-Infinity"); + } else { + return string_new("Infinity"); + } + } + if (value == 0.0) { + if (signbit(value)) { + return string_new("-0"); + } else { + return string_new("0"); + } + } + + char buffer[128]; + int written = snprintf(buffer, sizeof(buffer), "%.17g", value); + if (written < 0 || (size_t)written >= sizeof(buffer)) { + return string_empty(); + } + + // snprintf obeys LC_NUMERIC. Normalize its (possibly multibyte) decimal + // point without mutating the process-global locale; callers embedding + // Aether may have selected a non-C locale before reaching this function. + struct lconv* lc = localeconv(); + const char* decimal_point = lc ? lc->decimal_point : NULL; + if (decimal_point && decimal_point[0] && strcmp(decimal_point, ".") != 0) { + char* at = strstr(buffer, decimal_point); + if (at) { + size_t point_len = strlen(decimal_point); + at[0] = '.'; + if (point_len > 1) { + memmove(at + 1, at + point_len, strlen(at + point_len) + 1); + } + } + } + + return string_new(buffer); +} + // Inverse of string_to_int_radix: render `value` as a base-N digit // string. radix in [2, 36]; out-of-range radix yields the empty // string (caller-detectable, matches the existing string_empty() @@ -996,7 +1046,7 @@ int string_to_float_raw(const void* str, float* out_value) { errno = 0; float val = strtof(data, &endptr); - if (endptr == data || errno == ERANGE) { + if (endptr == data || (errno == ERANGE && (val == HUGE_VALF || val == -HUGE_VALF))) { return 0; } @@ -1015,7 +1065,7 @@ int string_to_double_raw(const void* str, double* out_value) { errno = 0; double val = strtod(data, &endptr); - if (endptr == data || errno == ERANGE) { + if (endptr == data || (errno == ERANGE && (val == HUGE_VAL || val == -HUGE_VAL))) { return 0; } diff --git a/std/string/aether_string.h b/std/string/aether_string.h index 69df66ec..3a53d2fd 100644 --- a/std/string/aether_string.h +++ b/std/string/aether_string.h @@ -196,6 +196,10 @@ AetherString* string_from_long(long long value); // ABI-mismatch hazard caused `from_float(1.0)` to serialise as `"0"` // — see the .c-side comment for the full diagnosis. AetherString* string_from_float(double value); +// Lossless, deterministic double-to-decimal text conversion pairing with +// `string.to_double`. Round-trips every finite IEEE-754 binary64 value, +// and normalizes special values. +AetherString* string_from_double(double value); // Inverse of string_to_int_radix: render `value` in base `radix` // (2..36). Empty string on out-of-range radix; '-' prefix for diff --git a/std/string/module.ae b/std/string/module.ae index d55f06d0..5c3ac8fa 100644 --- a/std/string/module.ae +++ b/std/string/module.ae @@ -31,6 +31,7 @@ exports( string_seq_each, string_seq_map, string_seq_filter, string_seq_reduce, string_seq_zip_each, string_to_cstr, string_from_int, string_from_long, string_from_float, + string_from_double, string_from_int_radix, string_pad_start, string_pad_end, string_to_int_raw, string_to_long_raw, string_to_float_raw, string_to_double_raw, string_try_int, string_get_int, string_try_long, string_get_long, @@ -323,6 +324,7 @@ extern string_to_cstr(str: string) -> string extern string_from_int(value: int) -> ptr extern string_from_long(value: long) -> ptr extern string_from_float(value: float) -> ptr +extern string_from_double(value: float) -> string // Inverse of to_int_radix. Render `value` in base `radix` (2..36). // Returns an empty string for radix outside [2, 36]; otherwise the diff --git a/tests/regression/test_string_double_roundtrip.ae b/tests/regression/test_string_double_roundtrip.ae new file mode 100644 index 00000000..f19c4fe5 --- /dev/null +++ b/tests/regression/test_string_double_roundtrip.ae @@ -0,0 +1,114 @@ +import std.mem +import std.string + +check_roundtrip(bits: long) -> int { + value = mem.float_from_bits(bits) + text = string.from_double(value) + parsed, err = string.to_double(text) + parsed_bits = mem.bits_of_float(parsed) + + ok = 1 + if err != "" || bits != parsed_bits { + ok = 0 + } + + if ok == 0 { + orig_hex = string.from_int_radix(bits, 16) + parsed_hex = string.from_int_radix(parsed_bits, 16) + println(" FAIL: bit mismatch!") + println(" Original bits: ${orig_hex}") + println(" Emitted text: ${text}") + println(" Parse error: ${err}") + println(" Parsed bits: ${parsed_hex}") + string.free(orig_hex) + string.free(parsed_hex) + } + + string.free(text) + string.free(err) + + if ok == 1 { + return 0 + } + return 1 +} + +check_text(value: float, expected: string, label: string) -> int { + text = string.from_double(value) + ok = 1 + if string.equals(text, expected) != 1 { + ok = 0 + println(" FAIL ${label}: got \"${text}\", expected \"${expected}\"") + } else { + println(" PASS ${label}: ${expected}") + } + string.free(text) + if ok == 1 { + return 0 + } + return 1 +} + +main() { + println("=== Running IEEE-754 binary64 round-trip tests ===") + fails = 0 + + // Required raw bit patterns + fails = fails + check_roundtrip(0x0000000000000000) + fails = fails + check_roundtrip(0x8000000000000000) + fails = fails + check_roundtrip(0x0000000000000001) + fails = fails + check_roundtrip(0x000fffffffffffff) + fails = fails + check_roundtrip(0x0010000000000000) + fails = fails + check_roundtrip(0x3ff0000000000000) + fails = fails + check_roundtrip(0x3ff0000000000001) + fails = fails + check_roundtrip(0x3fefffffffffffff) + fails = fails + check_roundtrip(0x3fb999999999999a) + fails = fails + check_roundtrip(0x400921fb54442d18) + fails = fails + check_roundtrip(0x7fefffffffffffff) + + // Negative counterparts + fails = fails + check_roundtrip(0x8000000000000001) + fails = fails + check_roundtrip(0x800fffffffffffff) + fails = fails + check_roundtrip(0x8010000000000000) + fails = fails + check_roundtrip(0xbff0000000000000) + fails = fails + check_roundtrip(0xffefffffffffffff) + + // Ordinary expected text tests + fails = fails + check_text(0.0, "0", "zero text") + fails = fails + check_text(mem.float_from_bits(0x8000000000000000), "-0", "neg zero text") + fails = fails + check_text(1.0, "1", "1.0 text") + fails = fails + check_text(-2.5, "-2.5", "-2.5 text") + + // Special values: Infinity, -Infinity, NaN + inf_val = mem.float_from_bits(0x7ff0000000000000) + neginf_val = mem.float_from_bits(0xfff0000000000000) + nan_val = mem.float_from_bits(0x7ff8000000000000) + + fails = fails + check_text(inf_val, "Infinity", "Infinity text") + fails = fails + check_text(neginf_val, "-Infinity", "-Infinity text") + fails = fails + check_text(nan_val, "NaN", "NaN text") + + // Deterministic loop over several thousand generated finite bit patterns. + var state = 1234567890123456789 + var count = 0 + var loop_fails = 0 + while count < 5000 { + state = (state *6364136223846793005) + 1442695040888963407 + if (state &0x7ff0000000000000) != 0x7ff0000000000000 { + loop_fails = loop_fails + check_roundtrip(state) + count = count + 1 + } + } + if loop_fails > 0 { + println(" FAIL: ${loop_fails} loop patterns failed.") + fails = fails + loop_fails + } else { + println(" PASS: 5000 deterministic pseudo-random finite bit patterns round-tripped perfectly.") + } + + if fails > 0 { + println("FAIL: Some tests failed.") + exit(1) + } + println("All PASS") +}