f32, and the program that asked for it
Status: F1, F2 and F3 are built; F4 is not. The type, the suffix literal,
+ - * /and unary minus, the six comparisons and the conversions (f32_of,float_of32,bits_of32,f32_of_bits) are on both backends, and F1’s gate passes on both: every operation agrees bit for bit with binary64 over 10⁶ random operand pairs plus 614² special-value pairs (and, once, over 10⁸), in 0.45 s on Cranelift and 0.27 s on LLVM (§5.1). F2 addedsqrt32,f32_of_intandint_of_f32(§5.2). F3 isstd.fmt32(f32_into,f32_fixed_into,f32_of_text): printing is byte for byte Rust’s{:?}and{:.N}, checked against all 2³² patterns, and reading is correctly rounded straight to binary32 (§5.3). It is edition 6, with no edition of its own (§6). Where the build disagreed with the design this document says so in place: the conversion isfloat_of32, notfloat_of(§2); the gate’s first oracle can be folded away by LLVM and has a second beside it (§5.1); a struct’sf32field is still eight bytes, and only a[f32]is four (§2); anf32staticis deferred (§5); the printing functions live instd.fmt32, notstd.fmt(§5.3). Not built:%onf32(floathas none),cancho-gpu’s swap (F4).
floating-point.md§7 has carried the row “f32: a second width drags conversion rules behind it, and nothing has asked” sincefloatlanded. Something has asked:cancho-gpu(cancho#251) islex-gpu’s.lxcompiler written in cancho, with a reference interpreter whose results must matchlex-gpu’s Rust byte for byte, andlex-gpu’s numbers aref32. This document says what the asker pays without the type, what the type would have to be, and what it must not become.
1. What the absence costs, measured
cancho-gpu’s interpreter (src/interp.cho, src/f32.cho) does every f32 operation as a
binary64 operation followed by an integer rounding to 24 significant bits (bits_of, then
math.ldexp twice). Printing is Steele and White shortest-form with ties up, to match Rust’s
{:?}; {:.4} is exact ties-to-even. All 135 cases of its differential test agree with the Rust.
That is the evidence the semantics can be written in cancho. The cost is in
cancho-gpu/docs/design.md §10.2: gemm_mma and gemm_fp4 at 128³ take 0.33 s and 0.28 s
against 13 and 17 ms for the Rust release build, about 20×; the 64×4096 matvec 0.18 s
against 39 ms. All of the difference is rounding, so it is the number this document is trying
to remove, and the number the first slice must re-measure on the type (§5).
Two other programs in this repository’s orbit are f32 programs without saying so:
ds4-class inference engines (weights and activations in f16/bf16/f32 and
block-quantised integers) and anything that reads a GGUF or safetensors header.
2. The type
let x: f32 = 0.5f32; // a literal carries its width: no inference from context
let y = x + 1.0f32;
let z = float_of32(x); // f32 -> float: exact, never traps
let w = f32_of(z); // float -> f32: rounds to nearest even, overflow gives infinity
- IEEE-754 binary32, round to nearest even, with the same answers to
floating-point.md§§2, 3, 5 asfloat: NaN propagates and breaks comparison,-0.0exists, the optimiser does not reassociate, and a fused multiply-add is never introduced silently. - The literal. A float-shaped literal (a point with digits on both sides, or an exponent) may
end in the suffix
f32:0.5f32,1e3f32,-0.0f32,1_000.25f32. The suffix is part of the token. It is read once, from the decimal, byf32::from_str: reading it as afloatand narrowing would round twice, and1.0000000596046447753906251f32shows it (a decimal one digit above the tie1 + 2⁻²⁴: rounded once it is0x3f800001; through binary64 it becomes the tie and rounds to even, down,0x3f800000;tests/accept/f32.chopins the first). An integer-shaped literal takes no suffix, so2f32is refused (unexpected-character), for the reasonfloatneeds a point or an exponent; the only other suffix is notf64(refused, same tag), because that is the typefloatalready is. A literal too large forf32(1e39f32) is refused (literal-out-of-range), as one too large forfloatis, and infinity isf32_of_bits(2139095040). A bare1.0never becomes anf32(§4).-1.5f32is one literal, like-1.5, and-(1.5f32)is a negation. - No implicit conversion between
f32andfloat, in either direction, and nof32 + float, nof32 + int, and no comparison across widths: all aretype-mismatch, blamed on the operand that disagrees. This is the one decision that keeps “a second width drags conversion rules behind it” from being true: there is one rule, written by the programmer at each crossing.f32_ofis a rounding and says so;float_of32is exact. - The four conversions are builtins, and the name is
float_of32, notfloat_of. The first draft of this document called the exact wideningfloat_of(x), and that name is taken: it isint -> float(floating-point.md§4), a builtin has one signature, and overloading it on its argument would be the first builtin whose type depends on a call site’s. So the width is part of the name, as inbits_of32:f32_of(x: float) -> f32,float_of32(x: f32) -> float,bits_of32(x: f32) -> int,f32_of_bits(n: int) -> f32.float_ofis unchanged and refuses anf32(float_of(1.5f32)isexpected int, found f32). f32 -> intandint -> f32followfloating-point.md§4 (the one conversion that traps keeps trapping), withint -> f32rounding to nearest even:int_of_f32andf32_of_int. F2; built (§5.2).- Operators, comparison,
std.math.+ - * /, unary minus and the six comparisons are built (%is refused,type-mismatch, becausefloathas none;sqrt32is F2).sqrt32is one instruction on both backends, so it is a builtin (float-math.md§3). Everything else instd.mathstays overfloat: a library overf32is the library’s own question, and the correctly-rounded route (compute infloat, round once) is available to it and is whatcancho-gpu’sexp32/ln_1p32already do. - Printing and parsing:
std.fmt32.f32_intowrites the shortest decimal that reads back to the samef32, laid out as Rust’s{:?};f32_fixed_intois{:.N};f32_of_textreads a decimal to the nearestf32in one rounding. It is a module of its own (edition 6), and notstd.fmtas first written. The oracle is Rust’s own formatting, which is what the program that emulatesf32today (cancho-gpu/src/f32.cho::debug) was written to match. F3; built (§5.3). - Bits:
bits_of32(x) -> intandf32_of_bits(n) -> f32(low 32 bits).float-printing.md§8 left the inverse ofbits_offor “when something needs it”; this needs both, andcancho-gpuneeds them to writef16and the fp4/fp8 encodings.bits_of32zero-extends, sobits_of32(-1.0f32)is3212836864, not negative, and every NaN reads as0x7fc00000(2143289344), for the reasonbits_ofcanonicalises: x86-64 generates a NaN with the sign set and aarch64 without (floating-point.md§4.1).f32_of_bitskeeps what it is given, payload included, and uses only the low 32 bits. - Layout: a
[f32]slice is four bytes an element, so that it and a GPU buffer or a GGUF tensor are the same bytes (layout.md); the backends’strideis 4 asbyte’s is 1 (cancho-codegen’slayout::stride_ofandcancho-codegen-llvm’sstride_of, one arm each). A struct field is not. The draft said “four bytes, four-aligned in a struct and in a slice”, and that was wrong about this compiler: every leaf of a struct, a tuple or a spilled value is an eight-byte slot (layout.md§2 measured it andcancho layoutstill reports it: astruct { a: f32, b: f32 }is 2 leaves, 16 bytes, 8 if packed). Packing leaves to their natural width is that document’s unbuilt change, it applies toboolandbyteleaves alike, and F1 does not smuggle it in. What the stride change cost was small, as the brief hoped: the load or store of the one leaf already uses the leaf’s own machine type, so only the number an index is multiplied by changed. A[byte]is not reinterpreted as[f32]: reading one isf32_of_bitsover four bytes, and the compiler is allowed to make that a load.
3. Not in this document: f16, bf16, integer SIMD
cancho-gpu also rounds to f16 and to e2m1/e4m3, and an inference engine wants f16
and bf16 as storage. The case is weaker than f32’s, because every consumer converts to
f32 before arithmetic, so a storage type with f32_of_f16/f16_of_f32 and no arithmetic is
enough. It is stated here so that the f32 slice does not close the door on it, and left for
its own document once a program shows a loop where a bit-twiddling conversion is the cost.
4. What it must not become
- Not a family of widths.
f32only. Each further width is its own measured case. - Not implicit. If a design review finds one place where it would be convenient for a
literal
1.0to become anf32, the answer is thef32suffix, not an inference rule. - Not an excuse to weaken
float.floatstays binary64, andfloat-printing.mdstays true of it.
5. Plan, each step with its own gate
| step | what | gate |
|---|---|---|
| F1 (built, §5.1) | the type, literals, + - * /, comparisons, f32_of/float_of32/bits_of32/f32_of_bits, both backends | for every f32 operation, on 10⁶ random pairs plus every special value, the result is bit-identical to f32_of(float_of32(a) op float_of32(b)) (binary64 holds an exact product and a correctly rounded sum of two binary32 values, so the emulation is the oracle) |
| F2 (built, §5.2) | sqrt32, f32_of_int, int_of_f32 (% has no float counterpart, so none); the traps floating-point.md §4 lists, restated for the width | a must-reject case per conversion rule, each with a rule tag; integer-only oracles for all three on both backends |
| F3 (built, §5.3) | std.fmt32: f32_into, f32_fixed_into, f32_of_text (not std.fmt: §5.3) | printing is byte for byte Rust’s {:?} and {:.N} and reading is str::parse::<f32>’s, every one of the 2³² patterns checked against Rust’s printing by digest and by round trip and shortness inside the program (run once and recorded); 10⁶ and more differential cases per function; each function’s rule broken on purpose and the tests watched to fail |
| F4 | cancho-gpu swaps its emulation for the type | its differential suite (135 cases) unchanged, and the 20× above re-measured: the claim is that the interpreter gets within a small factor of the Rust, and if it does not, this document says so |
The binary64-oracle claim in F1 rests on a classical result (Figueroa, 1995): when the wide
format has at least 2p + 2 significand bits over the narrow format’s p, rounding the wide
result of + - * / or sqrt to the narrow format gives the correctly rounded narrow result,
the double rounding being innocuous. Here 53 >= 2 * 24 + 2 = 50. It is nonetheless tested,
not trusted, which is why F1’s gate is a test and not the citation. (The same fact is what
cancho-gpu’s emulation already relies on, and its 135 differential cases are the first
evidence it holds on this compiler’s float.)
5.1 What F1 measured
The gate is tests/programs/f32_gate.cho, a cancho program, run on each backend by
crates/cancho/tests/conformance/binary32.rs. Operands are made from bit patterns with
f32_of_bits and nothing in the program is a literal, so what runs is each backend’s real binary32
instruction and not a constant the front end folded (an f32 literal is never folded, and an
operation on two stays an operation: fold.rs has no f32 arm, so there is no host-f32
computation in the compiler to disagree with the backends). Every operation (+ - * / and the six
comparisons) sees the same operand pairs:
- every special value against every special value, in both orders: both zeros, both signs of every normal power of two (254) and of the 23 subnormal ones, the extremes, the neighbours of 1, 3, 2²³ and 2²⁴, the infinities, NaNs of both signs and with payloads, and a few decimals: 614 values, 376,996 pairs;
- 10⁶ random pairs (
splitmix64, fixed seed, so a failure reproduces), in nine classes that are chosen to make rounding hard rather than to be uniform: random bit patterns; magnitudes within two binades of each other (cancellation); values near 1; subnormals; values near overflow; a second operand chosen so that the product lands at the top or bottom of the exponent range; the same for the quotient; a second operand 23, 24 or 25 binades below the first, half the time a power of two (an exact tie in a sum); and the same binade with nearly the same mantissa (exact differences, subnormal results). Mantissas are boundary ones (0, 1,0x400000,0x7fffff) one time in four.
Two oracles, and a mismatch against either fails.
f32_of(float_of32(a) op float_of32(b)): the oracle this document named. It cannot be the only one, and that is a correction to §5’s first draft: LLVM at-O2rewritesfptrunc(fadd(fpext a, fpext b))tofadd a, b, which is legal for exactly Figueroa’s reason, so on the LLVM backend the oracle becomes the instruction under test (measured: a function returningbits_of32(f32_of(float_of32(a) + float_of32(b)))compiles to oneaddss). Cranelift does not do it, so the oracle is real there and vacuous on the other backend.- The same binary64 result rounded to binary32 by integer arithmetic, written in the program
(
round32: sign, exponent and 52-bit mantissa taken apart, 29 bits shifted off, ties to even, the subnormal and overflow cases by hand). Nothing an optimiser knows about floating point reaches it, and it checksf32_ofitself as a side effect.
Comparisons have the same two: the comparison on the promoted operands (exact), and a comparison
made on the bit patterns with integers only (key folds sign and magnitude into one integer, NaN is
unordered). The second is there because of a mutation (below): float and f32 compare with the
same code in both backends, so a bug in it moves them together and the first oracle cannot see it.
NaN results are compared as “both NaN”, since a generated NaN’s payload is the hardware’s.
Result: 0 mismatches, in every operation, on both backends, over 1,376,996 pairs each; and
0 over 100,376,996 (10⁸ random pairs) when f32_gate is run by hand, which took 38.7 s on
Cranelift and 14.6 s on LLVM.
Run time. The default is the full 10⁶: the gate program runs in 0.45 s on Cranelift and 0.27 s
on LLVM (1,376,996 pairs, ten operations each, both oracles; measured with
cargo test -- --nocapture, build excluded), and the whole of binary32.rs in about 1.5 s. So
nothing is opt-in. LEX_F32_PAIRS=<n> overrides the random count, for the longer run above.
It could have failed. “No mismatch” is only worth something if the operands made rounding hard, so the program also counts what it reached, and the test requires it (at the default count):
| per operation, over 1,376,996 pairs | rounded results | exact ties (normal range) | subnormal results that were rounded | finite operands, infinite result | exactly zero |
|---|---|---|---|---|---|
+ | 464,480 | 129,373 | 0 | 9,387 | 17,342 |
- | 464,560 | 129,644 | 0 | 9,380 | 17,228 |
* | 645,934 | 25,324 | 283,224 | 280,755 | 24,279 |
/ | 758,929 | 0 | 93,222 | 98,791 | 6,890 |
(The zeros are expected: a sum of two binary32 values is exact in the subnormal range, so none is ever rounded there, and a quotient is never exactly halfway between two binary32 values.) And the gate was broken, on purpose, and watched to fail:
| mutation | result |
|---|---|
Cranelift f32 add emitted as fsub | +: 1,016,051 mismatches against both oracles; first 0 + min-normal |
Cranelift f32 add done in binary64, the significand cut to 24 bits (round toward zero), then narrowed | +: 231,171 mismatches against both oracles; first min-normal + (−2⁻¹⁰¹), an exact tie that rounds to even, gives 0x8cffffff for 0x8d000000 |
Cranelift f32_of (fdemote) rounding toward zero | +: 231,171 mismatches against oracle 1 and 0 against oracle 2: the two are independent, and the integer one is the one that stayed right |
LLVM f32 add emitted as fsub | +: 1,016,051 mismatches against both oracles |
Cranelift != on floats ordered, so NaN != NaN is false (it shares code with float) | !=: 0 against the binary64 oracle and 10,587 against the integer one: the first oracle alone would have passed it; first 0 != NaN |
Each was reverted; git diff of the two backends shows only the F1 change.
What the layout cost (§2): the stride is the only thing that changed for a slice, and
an_f32_slice_is_four_bytes_an_element (cancho-codegen) pins stride == 4 against the layout
report. Both backends lower the same program for aarch64 and x86-64 object formats
(f32_lowers_on_every_target), and agree byte for byte on tests/accept/f32.cho and on the gate’s
report (binary32.rs).
Not done in F1, on purpose. An f32 in a static (static t: [f32] is refused with
static-item, like any element type but int, byte, bool and float): the evaluator’s store
holds Expr::Int | Bool | Float and has no f32 arm, which is also why nothing folds; it is a
small addition when a program asks for a table. f32 crossing spawn and extern fn are as
float’s are (not allowed). A struct’s packed layout (§2). sqrt, %, int conversions, f32 in
std.math and std.fmt (F2, F3).
Found on the way. print rendered -(1.5) as -1.5, which reparses as the literal -1.5:
the negation of a floating literal came back as a different tree. The integer literal already had
the guard (-(5)); it now covers float and f32 as well, and an_f32_literal_round_trips
covers both.
5.2 What F2 measured
sqrt32 is one square-root instruction on both backends, f32_of_int converts straight from the
integer (sitofp i64 to float; going through binary64 rounds twice and 13,031 of 3.2M gate inputs
come out different), and int_of_f32 is truncate’s rule at the narrower width. The gate is
tests/programs/f32_convert_gate.cho with integer-only oracles for all three, 0 mismatches on both
backends (figures as recorded in F2’s commit, 5ca3ddd, not re-measured for F3). It also found that
Cranelift’s fcvt_to_sint accepted exactly -2⁶³ where floating-point.md §4 says any magnitude at
or beyond 2⁶³ traps; both truncate and int_of_f32 now check the bound.
5.3 What F3 built and measured
What it is, and one divergence from the plan. std/fmt32.cho, module std.fmt32:
f32_into(out: &!o [byte], x: f32) -> [] int | Rust’s {:?} for an f32: the shortest digits that read back, positional from 1e-4 up to below 1e16 with at least one digit after the point (0.5, 16384.0, 9999999000000000.0), exponential outside (1e-5, 1e16, 3.4028235e38), NaN, inf, -inf, 0.0, -0.0. Bytes written, or -1 if out is too short |
f32_fixed_into(out, x: f32, prec: int) -> [] int | Rust’s {:.N}: the exact binary value, rounded half to even at the N-th place (0.125 at 2 is 0.12, 2.5 at 0 is 2), a negative value that rounds to zero keeps its sign (-0.00), NaN/inf whatever prec is. -1 if out is too short or prec is negative |
f32_of_text(text: &t [byte]) -> [] (bool, f32) | str::parse::<f32>’s grammar and answers: (false, 0.0f32) for anything else |
f32_of_decimal(negative, w: int, exp10: int) -> [] f32, decimal_bits, shortest | the pieces: a decimal w · 10^exp10 rounded once, and the digits of one pattern |
It is a module of its own and not std.fmt, where the plan put it, for one reason: f32 is
visible only from edition 6 (§6), and std/fmt.cho has no edition marker (it is below 6) and prints every float;
raising it to add three functions moves a module that did not need to move.
Printing: Ryu at binary32, with one change. The digits are Ryu’s (Adams, 2018): the shortest
decimal inside the interval that rounds to this f32, the closest to the true value among those of
that length, in 64-bit integer arithmetic against two tables of powers of five, no bignum. Rust
rounds an exactly halfway candidate pair up, and Ryu rounds it to even; shortest rounds up
(the last >= 5 in its final step), and that is the one place it differs from the published
algorithm. {:?} is then the layout rule above, which is not std.fmt.float_into’s d.ddde-k
form (float-printing.md §1.1 left positional notation out on purpose).
Fixed point expands the exact value digit by digit in integer arithmetic: a whole part of at most 128 bits in four limbs, a fraction of at most 149 bits in at most five limbs that multiplying by ten carries a digit out of, and the tie decided from what is left after the N-th digit.
Reading, and how it differs from cancho-gpu’s parse. cancho-gpu/src/f32.cho::parse reads
the text as a float and rounds that to binary32, which rounds twice and is wrong when the first
rounding lands exactly on a binary32 midpoint the decimal was not on. f32_of_text is
correctly rounded directly: the digits are held exactly (up to 160 of them and then a sticky
bit: no binary32 midpoint has more than 113 significant digits (computed over the extremes of every exponent), so nothing past 160 matters except
as “more”), scaled by integer arithmetic until the quotient has 26 or 27 bits, and rounded once, the
remainder being the sticky bit. A fast path takes the common case (at most 16 digits, |q| <= 22:
two exact binary64 operands, one correctly rounded operation, then to binary32), and sends to the
exact path any result that is exactly a binary32 midpoint in binary64, which is the one case the
double rounding can get wrong. tests/accept/f32_text.cho pins 1.0000000596046447753906251 to
0x3f800001 (through binary64 it becomes the tie 1 + 2⁻²⁴ and rounds to even, 0x3f800000;
parse_does_not_round_twice shows both numbers) and the same decimal read as a literal (§2) gives
the same bits.
What the language accepts. Rust’s grammar, mirrored and not diverged from, and checked against
Rust’s parser line by line: an optional sign; then inf, infinity or nan in any case; or digits
with an optional point (5., .5 and 5.5, but not .) and an optional e/E exponent with an
optional sign and at least one digit; nothing before, between or after (so " 1", "1 ", "",
"1e", "1_0", "0x10" are all (false, ..)). Overflow is infinity and underflow zero, signed;
an exponent too big to hold is clamped, which cannot change the answer. Every NaN spelling gives the
canonical NaN (0x7fc00000; the sign of -nan is not kept, as bits_of32 would not show it).
No input traps. A short buffer, a negative or enormous prec, an exponent or a w at the end
of the int range are all answers (-1, or infinity and zero), not traps; the first two had been
traps (prec + 2 and decimals + q overflowed) until tests/accept/f32_text.cho tried them. The
module returns no refusal, so it needs no rule tag and no tests/reject fixture.
The gate.
- Every 2³² patterns, once.
tests/programs/f32_exhaustive.choprints each non-NaN pattern withf32_intointo a buffer and (1) hashes the bytes (FNV-1a 64 over each line and a newline), (2) reads the line back withf32_of_text, which must give the same bits, zero’s sign included, and (3) checks it is shortest independently of how it was found: neither of the two decimals one digit shorter that bracket it (the digits without the last, and that plus one) may read back to the same bits, and the set of reals that read back to onef32being an interval, no shorter decimal at all can then. Its digest is held againsttests/programs/f32_oracle.rs, which prints Rust’s{:?}for the same patterns (rustc -O) and hashes the same way, so equal digests mean every line is byte for byte Rust’s, including which of several shortest strings is chosen. The closest-to-the-true-value rule is therefore checked by the digest and not by a separate search. The run is cut into 64 ranges of 2²⁶ patterns, each result kept as a file (done.<k>), so a killed run starts again where it stopped. Result, run by the opt-in test itself on the final code: 64 of 64 ranges, 4,278,190,082 printed patterns (2³² less the 2·(2²³−1) NaNs), every range’s digest equal to Rust’s, 0 that did not read back, 0 that were not shortest, in 782 s of wall time on four cores (LLVM build of the program). An earlier run of the same program before the last refactor (three at a time, with other work running) took 1,204 s and gave the same counts and digests. Over all of them the longest output is 19 bytes (-9999999000000000.0) and the most digits in a shortest string is 9, sof32_intoneeds 19. - In the default suite, a sample of that: for each sign and exponent, 19,500 fractions spread by
a stride plus nineteen boundary ones, 9,954,694 patterns on LLVM in 7.6 s and 1,004,194 on
Cranelift in 3.1 s (the digest, the read-back and the shortness all checked). The whole run is
opt-in:
LEX_F32_EXHAUSTIVE=1 cargo test f32_text_exhaustive -- --nocapture(LEX_F32_EXHAUSTIVE_DIRfor where the ranges are kept). - Differentials against Rust, each in a per-test scratch directory with its own
rustc -Obuild of the oracle:{:?}over 1,000,000 patterns on LLVM and 250,000 on Cranelift (half uniform over the 32 bits, a quarter random with a random exponent, a quarter with few significant bits, then the zeros, extremes and infinities);{:.N}for N in 0, 1, 2, 3, 4, 5, 6, 8, 10, 12, 20, 45 and 60, 1,014,143 pairs on LLVM (264,134 on Cranelift), 14,144 of them constructed exact ties:(2a + 1) / 2^(N + 1)is a decimal with N + 1 places ending in 5 and anf32, so it is halfway at N, and alsok + 0.5,k + 0.25,k + 0.125and so on, and runs of nines next to carries;parseover 1,349,823 strings on LLVM (344,227 on Cranelift): 1,000,000 random of 1 to 40 digits with a point, leading zeros, a sign and an exponent from -300 to 40, 353 hard cases (spellings,nan/inf/infinityin every case and sign, junk before and after, the empty string, overflow and underflow at and either side of the boundary, the largest finite and the smallest subnormal, the halfway points at 40 and more digits, 200-digit and 300-zero strings), 25,000 exact midpoints between neighbouringf32s with one digit above and below (up to 113 digits, subnormals included), 224,470 short decimals next to a midpoint (their digits cut to 14 to 17 and rounded either way, which is what the fast path reads) and{:?}and{:e}output of randomf32s. 0 mismatches in each. - The fixture
tests/accept/f32_text.choruns on both backends and says the same on each.
It could have failed. Each rule was broken on purpose in std/fmt32.cho, the suite run, and the
file restored:
| mutation | result |
|---|---|
print: the exact tie rounds down (last > 5) | {:?} and sample digest tests fail on both backends (4 tests) |
| print: the shortest search stops one digit late | 5 tests fail: both {:?}, both sample digests, and the fixture |
| print: the nearest of the shortest is not picked (no rounding by the removed digit) | the same 5 |
| fixed: an exact tie rounds up instead of to even | {:.N} on both backends and the fixture |
| parse: a tie always rounds up | 5 tests fail, among them the sample’s read-back |
| parse: the sticky bit is dropped (a tie ignores what is below it) | 6 tests fail, among them parse_does_not_round_twice |
| parse: the fast path’s midpoint guard removed (so binary64 then binary32, double rounding) | passed the first time: the 1,000,000 random strings never reach it. The near-midpoint strings above were added for it; now 8,577 of 1,349,823 differ, the first 0.7186756363353197e-6 (got bits 893446916, Rust 893446915) |
The last row is the one that matters: a guard that no test fails without is not a tested guard, and for ten million random decimals it was not.
Cost to the default suite. The f32_text module’s eleven tests (the exhaustive one returns at
once unless opted in) take 19.5 s when run alone on four cores; the conformance binary took 155.4 s
with them and 140.2 s with --skip f32_text, so about 15 s of wall time (the whole workspace
run is 3 min 34 s to 3 min 58 s). Memory is a few megabytes; each test builds its own oracle with
rustc -O into a scratch directory of its own.
Deferred. A direct comparison with cancho-gpu’s f32.cho::debug (a binary64 emulation) was not
made: Rust is the oracle cancho-gpu is itself held to, and the swap is F4. fixed over all 2³²
patterns is not exhaustive (it is differential on 10⁶ and constructed ties; its algorithm has no
shortness to check but N and the value are two dimensions). Reading to f32 from {:e}-style text
beyond Rust’s grammar (a _ separator, hexadecimal) is not offered.
6. The edition decision, and what is still open
No new edition. editions.md §5 classifies by what breaks: f32 is additive (a type name,
a literal suffix and four builtin names), which is the kind an edition absorbs, and the way an
edition absorbs it is by the addition being absent from earlier editions, not by a new edition
number. The repository’s own precedent for the latest edition is to add to it while no file at that
edition could have used the name (value_barrier and the dir_* builtins were added to edition 6
after it existed, each with the count). Counted first, as file-handles.md §4.2 asks:
| what | files containing it, of the 547 .cho files under tests/, examples/, std/, packages/ and benches/ | anywhere else |
|---|---|---|
f32 as an identifier (type, field, function, local) | 0 | 0 in crates/, scripts/ and docs/ outside this document and its two mentions; 0 in the git history of .cho files (the one textual hit is inside a hex string in std.ecdsa’s vectors) |
f32_of, float_of32, bits_of32, f32_of_bits | 0 | 0 |
So edition 6 gains the type and the four builtins, gated the way every such addition is:
f32 resolves as a type only in a file at edition 6 or later (function.rs’s name resolution), the
four builtins have since() == 6, and the suffix literal is refused below it
(literal-form, “needs edition 6;”). Below edition 6 the names are the file’s own again, which
is the part a count does not prove and a test does: tests/accept/f32_names_are_edition_five.cho
declares struct f32 and fn f32_of at edition 5 and runs; in an edition-6 file struct f32 is
builtin-redeclared (f32_cannot_be_redeclared.cho). The rest of the gating has a fixture each:
f32_literal_is_edition_six, f32_conversions_are_edition_six, f32_type_is_edition_six.
If a program ever does use f32 as a name at edition 6 or later, that is the case editions.md §6.2
says an edition is for, and it would be edition 7’s to open. Hashes: the tag for an f32
literal is appended (0x74), the type is hashed by its written name, and tests/golden.rs, which
pins the encoder’s output for existing programs, passes unchanged: nothing that exists moved.
| Question | Why it waits |
|---|---|
| Answered above: no new edition; edition 6, gated; count 0 of 547. | |
f32 as a map key or a hash input | -0.0/NaN, as for float (floating-point.md §5): no new answer is proposed |
| A vector type | Only after F4 shows what the scalar type did not remove |
A struct of f32s at four bytes a field | layout.md §2’s packing, for every leaf, not for f32 alone (§2) |
An f32 static | When a program asks; the evaluator needs the one arm (§5.1) |