From 13946219f550d5983ce71a51c867b27836a69c69 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 20 May 2026 18:24:57 +0200 Subject: [PATCH] fix(runtime): mirror surface `.0`-fallback in ailang_float_to_str MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runtime/str.c::ailang_float_to_str` now applies the `.0`-fallback already enforced by the surface printer's `write_float_lit` (crates/ailang-surface/src/print.rs lines 624-637): after the existing `%g` snprintf, if `x` is finite and the rendered buffer contains neither `.` nor `e`/`E`, append `.0` so a whole-valued Float renders with Float-shaped text. The `isfinite(x)` fence keeps `nan`/`inf`/`-inf` from matching the predicate and turning into `nan.0`/`inf.0`. The 64-byte stack buffer's truncation guard is extended by 2 bytes for the fallback path so the defensive `abort()` discipline holds. The alternative (document `%g`-without-fallback in the design/ ledger as a deliberate human-friendly contract) was rejected: the language's stated ethos is machine-readability and round-trip identity; the surface printer is the reference, runtime drift from it is a defect not a feature. Golden updates flow from the contract change. Three fixtures encoded the old Int-shaped output for Float values: - `crates/ail/tests/floats_e2e.rs`: `"4\n42\n-1.5\n"` → `"4.0\n42.0\n-1.5\n"` (1.5+2.5=4.0, int_to_float(42)=42.0). - `crates/ail/tests/e2e.rs::mut_sum_floats_prints_55`: `"55"` → `"55.0"`. The accompanying doc comment used to canonicalise the bug ("`%g` strips the trailing `.0` — so the canonical stdout for Float 55.0 is `55`"); rewritten to reflect the new contract. - `examples/mut_sum_floats.ail` docstring: same correction. Two doc comments are updated where the post-fix contract differs from the old text but the fixture golden does not change: - `examples/float_to_str_smoke.ail` docstring (3.5 still renders as `3.5` because `.` is already present — the fallback does not fire). - `crates/ail/tests/e2e.rs::float_to_str_smoke` doc comment (same observation, made explicit so the trip-wire's intent is clear). Verified: `cargo test -p ail --test print_float_whole_e2e` green (was RED at f19b0dd); `cargo test --workspace` 0 failed across all crates and integration suites. closes #7 --- ...-20-iter-bugfix-float-to-str-dot-zero.json | 12 ++++++++ crates/ail/tests/e2e.rs | 18 ++++++++---- crates/ail/tests/floats_e2e.rs | 7 +++-- examples/float_to_str_smoke.ail | 2 +- examples/mut_sum_floats.ail | 2 +- runtime/str.c | 29 +++++++++++++++++++ 6 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 bench/orchestrator-stats/2026-05-20-iter-bugfix-float-to-str-dot-zero.json diff --git a/bench/orchestrator-stats/2026-05-20-iter-bugfix-float-to-str-dot-zero.json b/bench/orchestrator-stats/2026-05-20-iter-bugfix-float-to-str-dot-zero.json new file mode 100644 index 0000000..de8f495 --- /dev/null +++ b/bench/orchestrator-stats/2026-05-20-iter-bugfix-float-to-str-dot-zero.json @@ -0,0 +1,12 @@ +{ + "iter_id": "bugfix-float-to-str-dot-zero", + "date": "2026-05-20", + "mode": "mini", + "outcome": "DONE", + "tasks_total": 1, + "tasks_completed": 1, + "reloops_per_task": { "1": 0 }, + "review_loops_spec": 0, + "review_loops_quality": 0, + "blocked_reason": null +} diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index d687241..6d45def 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -2702,7 +2702,12 @@ fn int_to_str_smoke() { /// default applies and the dev target's glibc produces the three /// bytes `3.5` (no trailing zeros). If a future target's libc or /// locale produces a different rendering, this assertion is the -/// trip-wire that surfaces it. +/// trip-wire that surfaces it. Note: post-Gitea-#7 the runtime +/// also applies a `.0`-fallback when `%g`'s output for a finite +/// double contains neither `.` nor `e`/`E` (mirroring the surface +/// printer's `write_float_lit`); `3.5` already contains `.`, so +/// the fallback does not fire here and the golden stays at the +/// three bytes `3.5`. #[test] fn float_to_str_smoke() { let out = build_and_run("float_to_str_smoke.ail"); @@ -2866,12 +2871,13 @@ fn mut_counter_prints_55() { /// Float twin of `mut_counter_prints_55`: `sum_helper` returns the /// sum 1.0+...+10.0 = 55.0. The polymorphic `print` routes through -/// `Show Float.show` → `float_to_str`'s libc `%g` formatter, which -/// strips the trailing `.0` — so the canonical stdout for Float -/// 55.0 is `"55"` (matches `examples/floats.ail`'s output of -/// `4` for `1.5 + 2.5`). +/// `Show Float.show` → `float_to_str`'s libc `%g` formatter plus +/// the runtime `.0`-fallback for finite whole-valued doubles whose +/// `%g` output lacks `.`/`e`/`E` (Gitea #7), so the canonical +/// stdout for Float 55.0 is `"55.0"` (matches `examples/floats.ail`'s +/// output of `4.0` for `1.5 + 2.5`). #[test] fn mut_sum_floats_prints_55() { let stdout = build_and_run("mut_sum_floats.ail"); - assert_eq!(stdout.trim(), "55", "mut_sum_floats must print 55, got {stdout:?}"); + assert_eq!(stdout.trim(), "55.0", "mut_sum_floats must print 55.0, got {stdout:?}"); } diff --git a/crates/ail/tests/floats_e2e.rs b/crates/ail/tests/floats_e2e.rs index c6a6522..c9e02b5 100644 --- a/crates/ail/tests/floats_e2e.rs +++ b/crates/ail/tests/floats_e2e.rs @@ -2,11 +2,12 @@ //! //! `examples/floats.ail.json` is built via the public `ail build` //! CLI and run; stdout is matched against the expected three -//! lines (`4`, `42`, `-1.5`). This test exercises the full +//! lines (`4.0`, `42.0`, `-1.5`). This test exercises the full //! pipeline — Float literal lowering, polymorphic `+` Float arm, //! `int_to_float` (sitofp), `neg` Float (fneg), and the polymorphic //! `print` for Float (routes through `Show Float.show` → -//! `float_to_str`, libc `%g`). +//! `float_to_str`, libc `%g` plus the runtime `.0`-fallback for +//! whole-valued Float results, per Gitea #7). use std::path::PathBuf; use std::process::Command; @@ -59,7 +60,7 @@ fn floats_example_prints_expected_stdout() { ); let stdout = String::from_utf8_lossy(&run.stdout); - let expected = "4\n42\n-1.5\n"; + let expected = "4.0\n42.0\n-1.5\n"; assert_eq!( stdout, expected, "stdout mismatch.\nGot:\n{}\nExpected:\n{}", diff --git a/examples/float_to_str_smoke.ail b/examples/float_to_str_smoke.ail index c4127a0..33aecdc 100644 --- a/examples/float_to_str_smoke.ail +++ b/examples/float_to_str_smoke.ail @@ -1,6 +1,6 @@ (module float_to_str_smoke (fn main - (doc "Iter hs.4: smoke pin for the float_to_str heap-Str builtin. `do io/print_str(float_to_str(3.5))` builds through the whole pipeline (checker resolves float_to_str:(Float)->Str, codegen lowers to call ptr @ailang_float_to_str(double 3.5), runtime/str.c's ailang_float_to_str snprintfs into a heap-Str slab via libc %g, io/print_str's @puts prints from the bytes pointer at offset 8). The exact rendering of 3.5 is libc-target-dependent; on the dev target's clang+glibc with default C locale it is the three bytes `3.5`. Single-value smoke; NaN / +Inf / -Inf edge cases are deferred per the hs.4 plan.") + (doc "Iter hs.4: smoke pin for the float_to_str heap-Str builtin. `do io/print_str(float_to_str(3.5))` builds through the whole pipeline (checker resolves float_to_str:(Float)->Str, codegen lowers to call ptr @ailang_float_to_str(double 3.5), runtime/str.c's ailang_float_to_str snprintfs into a heap-Str slab via libc %g plus a `.0`-fallback for finite whole-valued doubles whose %g output lacks `.`/`e`/`E` (Gitea #7), io/print_str's @puts prints from the bytes pointer at offset 8). The exact rendering of 3.5 is libc-target-dependent; on the dev target's clang+glibc with default C locale it is the three bytes `3.5` (the fallback does not fire because `.` is already present). Single-value smoke; NaN / +Inf / -Inf edge cases are deferred per the hs.4 plan.") (type (fn-type (params) (ret (con Unit)) (effects IO))) (params) (body (do io/print_str (app float_to_str 3.5))))) diff --git a/examples/mut_sum_floats.ail b/examples/mut_sum_floats.ail index 607e4aa..056ad8c 100644 --- a/examples/mut_sum_floats.ail +++ b/examples/mut_sum_floats.ail @@ -1,7 +1,7 @@ (module mut_sum_floats (fn main - (doc "Float twin of mut_counter via a tail-recursive helper. Expected stdout: 55 (Float-55.0 via %g).") + (doc "Float twin of mut_counter via a tail-recursive helper. Expected stdout: 55.0 (Float 55.0 via %g plus the runtime `.0`-fallback for finite whole-valued doubles, Gitea #7).") (type (fn-type (params) (ret (con Unit)) (effects IO))) (params) (body diff --git a/runtime/str.c b/runtime/str.c index 8230d3c..cba0666 100644 --- a/runtime/str.c +++ b/runtime/str.c @@ -18,6 +18,7 @@ #include #include #include +#include /* Defined in runtime/rc.c — exposed with default external linkage. * Returns a zero-initialised payload whose `rc_header` sits at @@ -125,6 +126,16 @@ char *ailang_int_to_str(int64_t n) { * oversized. Defensive `abort()` on truncation, same shape as * `ailang_int_to_str`. * + * `.0`-fallback (Gitea #7): if `x` is finite and `%g`'s output + * contains neither `.` nor `e`/`E`, append `.0` so a whole-valued + * Float renders with a Float-shaped suffix instead of Int-shaped + * text. Mirrors the surface printer's `write_float_lit` fallback + * at `crates/ailang-surface/src/print.rs` lines 624-637, keeping + * the surface/runtime round-trip-on-lex symmetry intact. The + * `isfinite(x)` fence skips the fallback for `nan`/`inf`/`-inf`, + * which would otherwise match the no-`.`-no-`e` predicate and + * render as `nan.0`/`inf.0`. + * * Returns the payload pointer of a fresh heap-Str slab. */ char *ailang_float_to_str(double x) { @@ -138,6 +149,24 @@ char *ailang_float_to_str(double x) { abort(); } uint64_t len = (uint64_t)written; + /* `.0`-fallback — see block comment above and the surface + * counterpart at crates/ailang-surface/src/print.rs lines + * 624-637. `%g` output is ASCII, so plain `memchr` suffices. */ + if (isfinite(x) + && memchr(buf, '.', len) == NULL + && memchr(buf, 'e', len) == NULL + && memchr(buf, 'E', len) == NULL) { + if (len + 2 >= sizeof(buf)) { + fprintf(stderr, + "ailang_float_to_str: .0-fallback would overflow buffer " + "(len=%llu, buffer=%zu) — should be unreachable for any finite double; aborting\n", + (unsigned long long)len, sizeof(buf)); + abort(); + } + buf[len] = '.'; + buf[len + 1] = '0'; + len += 2; + } char *payload = str_alloc(len); memcpy(payload + 8, buf, len); payload[8 + len] = '\0';