From 5afbc65414b31d16aa32036a4ad3d6aa3eac24bc Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 17:04:41 +0300 Subject: [PATCH] fix(backend): float `!=` must be UNORDERED so `nan != nan` is true [F0.9] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emitCmpNe lowered float `!=` to `LLVMRealONE` (ordered not-equal), which is false when either operand is NaN. That made `nan != nan` false in native code — breaking the canonical `x != x` NaN test, making `!=` non-complementary with `==` for NaN, and disagreeing with the interpreter. Change the float predicate to `LLVMRealUNE` (unordered not-equal): true if either operand is NaN OR they are unequal. For all non-NaN operands `UNE` ≡ `ONE`, so only NaN-involving comparisons change (toward correct). The integer predicate (`LLVMIntNE`) and `emitCmpEq` (`OEQ`) are unchanged, so `nan == nan` stays false and `!=` is now the exact complement of `==`. - Regression: examples/0150-types-float-ne-unordered-nan.sx (fails before, passes after; also pins #run/comptime == runtime agreement). - specs.md: documents float comparison / NaN semantics (Operators). - Resolves issue 0091 (issues/0091-float-ne-ordered-nan.md). --- examples/0150-types-float-ne-unordered-nan.sx | 35 ++++++++ .../0150-types-float-ne-unordered-nan.exit | 1 + .../0150-types-float-ne-unordered-nan.stderr | 1 + .../0150-types-float-ne-unordered-nan.stdout | 8 ++ issues/0091-float-ne-ordered-nan.md | 79 +++++++++++++++++++ specs.md | 9 +++ src/backend/llvm/ops.zig | 5 +- 7 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 examples/0150-types-float-ne-unordered-nan.sx create mode 100644 examples/expected/0150-types-float-ne-unordered-nan.exit create mode 100644 examples/expected/0150-types-float-ne-unordered-nan.stderr create mode 100644 examples/expected/0150-types-float-ne-unordered-nan.stdout create mode 100644 issues/0091-float-ne-ordered-nan.md diff --git a/examples/0150-types-float-ne-unordered-nan.sx b/examples/0150-types-float-ne-unordered-nan.sx new file mode 100644 index 0000000..0e8f98b --- /dev/null +++ b/examples/0150-types-float-ne-unordered-nan.sx @@ -0,0 +1,35 @@ +// Float `!=` is UNORDERED not-equal: `nan != nan` is true (the canonical +// `x != x` NaN idiom), and `!=` is the exact complement of `==` for every +// float input — including NaN, where `nan == nan` is false (ordered `==`). +// For all non-NaN operands unordered `!=` matches ordered `!=`, so finite +// comparisons are unchanged. The native backend agrees with the interpreter. +// +// Regression (issue 0091): the LLVM backend lowered float `!=` to ordered +// not-equal (LLVMRealONE), so `nan != nan` was false in native code. +#import "modules/std.sx"; + +main :: () { + // Produce a genuine NaN without any numeric-limit accessor: 0.0 / 0.0. + z := 0.0; + nan := z / z; + + // The fix: `!=` is unordered, `==` is ordered. + print("nan != nan: {}\n", nan != nan); // true + print("nan == nan: {}\n", nan == nan); // false + print("nan != 1.0: {}\n", nan != 1.0); // true + print("nan == 1.0: {}\n", nan == 1.0); // false + + // Complementarity holds for finite operands too (unchanged behavior). + print("1.0 != 2.0: {}\n", 1.0 != 2.0); // true + print("1.0 != 1.0: {}\n", 1.0 != 1.0); // false + print("2.0 != 2.0: {}\n", 2.0 != 2.0); // false + + // Native codegen converges with the comptime interpreter. + print("comptime nan != nan: {}\n", #run nan_ne_nan()); +} + +nan_ne_nan :: () -> bool { + z := 0.0; + n := z / z; + return n != n; +} diff --git a/examples/expected/0150-types-float-ne-unordered-nan.exit b/examples/expected/0150-types-float-ne-unordered-nan.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0150-types-float-ne-unordered-nan.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0150-types-float-ne-unordered-nan.stderr b/examples/expected/0150-types-float-ne-unordered-nan.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0150-types-float-ne-unordered-nan.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0150-types-float-ne-unordered-nan.stdout b/examples/expected/0150-types-float-ne-unordered-nan.stdout new file mode 100644 index 0000000..051f37d --- /dev/null +++ b/examples/expected/0150-types-float-ne-unordered-nan.stdout @@ -0,0 +1,8 @@ +nan != nan: true +nan == nan: false +nan != 1.0: true +nan == 1.0: false +1.0 != 2.0: true +1.0 != 1.0: false +2.0 != 2.0: false +comptime nan != nan: true diff --git a/issues/0091-float-ne-ordered-nan.md b/issues/0091-float-ne-ordered-nan.md new file mode 100644 index 0000000..fcfab78 --- /dev/null +++ b/issues/0091-float-ne-ordered-nan.md @@ -0,0 +1,79 @@ +# 0091 — float `!=` lowers to ORDERED not-equal, so `nan != nan` is false in native code + +> **RESOLVED** (F0.9). Root cause: `emitCmpNe` in `src/backend/llvm/ops.zig` +> passed `c.LLVMRealONE` (ordered not-equal) as the float predicate. Fix: +> `c.LLVMRealONE` → `c.LLVMRealUNE` (unordered not-equal). The integer predicate +> `LLVMIntNE` and `emitCmpEq` (`OEQ`) are unchanged. For all non-NaN operands +> `UNE` ≡ `ONE`, so only NaN-involving float `!=` changes (toward correct). +> Regression test: `examples/0150-types-float-ne-unordered-nan.sx`. Spec note +> added to `specs.md` (Operators → "Float comparison and NaN"). + +## Symptom + +The LLVM backend lowers float `!=` to `LLVMRealONE` (ordered not-equal), which +returns **false** when either operand is NaN. Consequences: + +- Observed: `nan != nan` evaluates to **false** (via `sx run`). +- Expected: **true** — `!=` must be the logical complement of `==`, and the + canonical NaN-detection idiom `x != x` must be true for a NaN. + +This makes `==` and `!=` non-complementary for NaN: `nan == nan` is false +(correct, `OEQ`) AND `nan != nan` is also false (wrong, `ONE`). It silently +breaks the standard NaN check used throughout numerical code +(`if x != x { /* NaN */ }`): NaN is never detected at runtime. + +## Reproduction (accessor-free) + +NaN is produced as `0.0 / 0.0` — no numeric-limit accessor required: + +```sx +#import "modules/std.sx"; +main :: () { + z := 0.0; + n := z / z; // NaN + print("ne={} eq={}\n", n != n, n == n); // observed: ne=false eq=false +} // correct: ne=true eq=false +``` + +`./zig-out/bin/sx run .sx` printed `ne=false eq=false` before the fix. +After the fix it prints `ne=true eq=false`. Non-NaN comparisons are unchanged +(`1.0 != 2.0` true, `1.0 != 1.0` false). The `#run`/comptime path (JIT-compiled +through the same backend) and the native runtime path agree in both states. + +## Root cause + +`src/backend/llvm/ops.zig`, `emitCmpNe`: + +```zig +pub fn emitCmpNe(self: Ops, instruction: *const Inst, bin: BinOp) void { + self.e.emitCmp(bin, instruction.ty, c.LLVMIntNE, c.LLVMRealONE); + // ^^^^^^^^^^^^^^^ ordered +} +``` + +`LLVMRealONE` = ordered not-equal (false if either operand is NaN). The IEEE/C +`!=` is `LLVMRealUNE` (unordered not-equal → true if either is NaN). For all +NON-NaN operands `UNE` and `ONE` are identical, so the fix changes behavior only +for the NaN case — bringing native codegen in line with `==` (`OEQ`) and with +the interpreter's `evalCmp` (`.ne => lf != rf`, which is unordered in Zig). + +`emitCmpNe` is the sole float-`!=` lowering site (dispatched from +`src/ir/emit_llvm.zig` `cmp_ne` → `ops().emitCmpNe`). There is no second backend +path (no `fcmp one` appears in any `.ir` snapshot; `src/codegen.zig` has no +float-`!=` lowering). + +## Fix + +```zig +pub fn emitCmpNe(self: Ops, instruction: *const Inst, bin: BinOp) void { + self.e.emitCmp(bin, instruction.ty, c.LLVMIntNE, c.LLVMRealUNE); +} +``` + +## Regression test + +`examples/0150-types-float-ne-unordered-nan.sx` asserts (runtime, exit 0): +`nan != nan` true, `nan == nan` false, `nan != 1.0` true, `nan == 1.0` false, +the finite cases (`1.0 != 2.0` true, `1.0 != 1.0` false, `2.0 != 2.0` false), +and that the `#run` comptime `nan != nan` matches the runtime one. It fails on +the pre-fix compiler (`nan != nan: false`) and passes after. diff --git a/specs.md b/specs.md index 93e175d..3b703db 100644 --- a/specs.md +++ b/specs.md @@ -85,6 +85,15 @@ GLSL; | `<<=` | left shift assign | | `>>=` | right shift assign | +**Float comparison and NaN.** Float `==` is *ordered* and `!=` is *unordered*, +matching IEEE 754: `==` is false whenever either operand is NaN (`nan == x` is +false for every `x`, including `nan`), and `!=` is true whenever either operand +is NaN (`nan != x` is true for every `x`, including `nan`). So `!=` is the exact +complement of `==` for all float inputs, and the canonical NaN test `x != x` is +true exactly when `x` is NaN. The ordered relations `<`, `<=`, `>`, `>=` are all +false when either operand is NaN. For all non-NaN operands these reduce to the +ordinary comparisons. Native codegen and the comptime interpreter agree on this. + ### Delimiters and Punctuation | Token | Meaning | diff --git a/src/backend/llvm/ops.zig b/src/backend/llvm/ops.zig index 6542735..df80e84 100644 --- a/src/backend/llvm/ops.zig +++ b/src/backend/llvm/ops.zig @@ -274,7 +274,10 @@ pub const Ops = struct { } pub fn emitCmpNe(self: Ops, instruction: *const Inst, bin: BinOp) void { - self.e.emitCmp(bin, instruction.ty, c.LLVMIntNE, c.LLVMRealONE); + // Float `!=` is UNORDERED not-equal: true if either operand is NaN, so + // `nan != nan` is true (IEEE 754 / the `x != x` NaN idiom) and `!=` stays + // the exact complement of `==` (OEQ). UNE == ONE for all non-NaN operands. + self.e.emitCmp(bin, instruction.ty, c.LLVMIntNE, c.LLVMRealUNE); } pub fn emitCmpLt(self: Ops, instruction: *const Inst, bin: BinOp) void {