#4750·baml

[bug] 0.17.0 canary: `0.0` and `-0.0` literals in the same function body alias to whichever is written first

Author: ritunjaymCreated Sep 4, 2026Updated Sep 4, 2026

Product

BAML

Describe the bug

When a 0.0 literal and a -0.0 literal are both used in the same function body, the second one silently takes the first one's value. Which sign survives depends purely on source order.

baml
function zero_signs() -> string throws never {
    (0.0).to_string() + "," + (-0.0).to_string()
}
// returns "0.0,0.0"   — expected "0.0,-0.0"

Reversing the order reverses the corruption, which rules out "-0.0 is simply not supported":

baml
function zero_signs_reversed() -> string throws never {
    (-0.0).to_string() + "," + (0.0).to_string()
}
// returns "-0.0,-0.0"  — expected "-0.0,0.0"

Either literal alone in a body is correct, so -0.0 is represented and printed properly; the defect is only in the interaction between the two.

The scope is one function body. Two functions in the same file, each using one of the zeros, are both correct. Values that reach the body at runtime rather than as literals are unaffected — a -0.0 produced by (-0.5).trunc() keeps its sign even when a 0.0 literal is present. A non-zero control pair (1.0 and -1.0) is unaffected.

Observed on canary at 304898ab3, baml-cli 0.17.0, Rust 1.93.0, macOS arm64.

Reproduction Steps

Add a file under baml_language/crates/baml_tests/baml_src/ns_zzprobe/probe.baml:

baml
function zero_signs() -> string throws never {
    (0.0).to_string() + "," + (-0.0).to_string()
}

function only_negative_zero() -> string throws never {
    (-0.0).to_string()
}

test "zero_signs" { assert.equal(zero_signs(), "0.0,-0.0") }
test "control_alone" { assert.equal(only_negative_zero(), "-0.0") }

Run:

bash
cd baml_language
cargo build -p baml_cli --bin baml-cli
./target/debug/baml-cli test --from crates/baml_tests/baml_src

Result:

PASS root.zzprobe::control_alone
FAIL root.zzprobe::zero_signs
AGGREGATE FAIL [outcome=fail]
  => assertion failed: left = "0.0,0.0", right = "0.0,-0.0"

to_string() is used as the observable because it renders the sign of a zero; the existing test float_abs_negative_zero relies on the same property, noting that BAML has no is_sign_positive(). The corruption is in the value itself, not in formatting — see the sort and atan2 rows below.

Evidence

Each case is a single function body. "Materialized" means the literal is actually used, not merely bound and discarded.

Body Expected Actual
-0.0 alone "-0.0" "-0.0" pass
0.0 alone "0.0" "0.0" pass
0.0 then -0.0, both as method receivers "0.0,-0.0" "0.0,0.0" FAIL
-0.0 then 0.0, both as method receivers "-0.0,0.0" "-0.0,-0.0" FAIL
both via let, both then printed "0.0,-0.0" "0.0,0.0" FAIL
0.0 as receiver, -0.0 via let "0.0,-0.0" "0.0,0.0" FAIL
0.0 via let, -0.0 as receiver "0.0,-0.0" "0.0,0.0" FAIL
both passed as function arguments "0.0,-0.0" "0.0,0.0" FAIL
0.0, -0.0, 0.0 — three mentions "0.0,-0.0,0.0" all "0.0" FAIL
let _unused = 0.0; then -0.0 used "-0.0" "-0.0" pass
0.0 literal + runtime -0.0 from (-0.5).trunc() "-0.0" "-0.0" pass
1.0 and -1.0 in one body (control) "1.0", "-1.0" as expected pass
two separate functions, one zero each correct correct pass

Position does not matter — receiver, let initializer, and call argument all trigger it, in any combination. What matters is that both literals are materialized in the same body. The unused-let row is the discriminator: a 0.0 that is bound but never used does not corrupt a later -0.0.

The corrupted value propagates into ordinary operations, so this is not a to_string() display quirk:

Case Expected Actual
[0.0, -0.0], print both elements "0.0,-0.0" "0.0,0.0" FAIL
[0.0, -0.0].sort(), print first element "-0.0" "0.0" FAIL
(0.0).atan2(-0.0) pi 0.0 FAIL
[0.0, (-0.5).trunc()].sort(), print first "-0.0" "-0.0" pass
(0.0).atan2((-0.5).trunc()) pi pi pass

The last two rows are the same operations with the -0.0 produced at runtime instead of written as a literal, and both are correct — so the operations themselves are fine and only the literal is corrupted.

The sort row is the clearest failure: float[].sort() goes through Comparable for float, which orders by IEEE 754 totalOrder and places -0.0 strictly before +0.0. The array should be reordered and silently is not, because by the time the sort runs both elements are +0.0.

atan2 is notable because the bug makes the literal form unwritable: the natural call (0.0).atan2(-0.0) contains both zero literals by construction, so on current canary there is no way to express it correctly with literals.

Likely cause

The behaviour is consistent with float literals being interned into a per-body constant pool whose key is compared with float equality rather than by bit pattern. 0.0 == -0.0 is true in IEEE 754, so the two literals collide on one entry; the first one materialized wins and every later mention resolves to it. That would explain the order dependence, why either literal alone is fine, why a bound-but-unused literal does not participate (no entry is created for it), why runtime-produced values are unaffected (they never go through the pool), and why 1.0 / -1.0 are safe (they are not equal, so no collision).

If that is right, the fix would be to key the pool on the bit pattern (f64::to_bits) or on total_cmp ordering rather than on ==.

I have not read the constant-pool implementation, so please treat the mechanism as inference from observed behaviour rather than a diagnosis. The trigger condition itself — both zero literals materialized in one body, first one wins — is established by the table above.

Impact

Any function that uses both 0.0 and -0.0 as literals and depends on the distinction gets a wrong value, with no diagnostic.

What makes this worth fixing above its apparent rarity is that it is close to invisible. 0.0 == -0.0 is true, so every equality assertion, comparison and arithmetic sanity check still passes; nothing looks wrong unless you specifically inspect the sign via to_string(), or hit one of the few operations that read the sign bit — total_cmp ordering and therefore float[].sort(), atan2, and the zero-sign preservation of floor / trunc / sqrt. Code that silently loses a signed zero here will keep passing its own tests.

It also lands hardest on exactly the code most likely to care: a test or utility that checks both signs of zero side by side is the natural way to write such a test, and it is the shape that breaks.

Notes

Found while writing tests for a float.signum() addition to the float stdlib (PR not yet filed). The test asserted (0.0).signum() == 1.0 and (-0.0).signum() == -1.0 in one body and failed on the second assertion; the implementation was correct and the literals were not.

Worth flagging for whoever fixes this: the existing test float_abs_negative_zero in crates/baml_tests/baml_src/ns_floats/floats.baml passes today, but coincidentally rather than because the sign handling is covered. Its -0.0 literals are written before its only 0.0 literal, so -0.0 wins the collision; and the one place the 0.0 is used is an assert.equal(...) comparison, where 0.0 == -0.0 is true and the corruption is invisible. I verified this: adding an assert.equal((0.0).to_string(), "0.0") line above its existing assertions flips that test to failing. It should not be read as existing coverage for this.

Happy to attempt a fix if that would be useful — this report is only about the bug.

Investigated with AI-assisted tooling, reviewed and verified by me before filing. The isolation work — narrowing from an unexpected test failure to the minimal two-literal repro, and the case table above — was done with AI assistance; I reviewed the reasoning and re-ran the repro and the impact cases myself before filing. Two findings that appeared in early investigation (-1.0 * 0.0 folding to +0.0, and a type-annotated let x: float = -0.0 losing its sign) were dropped after checking: both pass in isolation and were just this same aliasing bug observed through a second zero literal in the same body.

BAML Version

0.17.0 (canary @ 304898ab3)

Language/Framework

Other

LLM Provider

N/A — compiler bug, no LLM involved

LLM Model

N/A

Operating System

macOS

Browser

N/A

Code Editor

N/A