diff --git a/docs/contracts/README.md b/docs/contracts/README.md
index 79f72288..e01c8b6c 100644
--- a/docs/contracts/README.md
+++ b/docs/contracts/README.md
@@ -28,10 +28,10 @@ Evidence classes (weakest → strongest): `doc-only` < `by-construction` <
Measured per contract in [`proofs/contract-provenance.toml`](../../proofs/contract-provenance.toml)
(`scripts/check-contract-provenance.py`: the instant the id entered this ledger against the instant
-the `since` release was tagged). Of 370 contracts: **requirements-first 65** (two-repo regime, since 2026-08-20),
+the `since` release was tagged). Of 372 contracts: **requirements-first 67** (two-repo regime, since 2026-08-20),
contemporaneous 156, **retroactive 132** (shrink-only ceiling 132), unmeasured 17.
-370 contracts
+372 contracts
| ID | Contract | Since | Status | Strongest Evidence | # Fixtures |
|----|----------|-------|--------|--------------------|-----------:|
@@ -405,4 +405,6 @@ contemporaneous 156, **retroactive 132** (shrink-only ceiling 132), unmeasured 1
| C-368 | http router: a handler is a function, the most specific route answers, a broken table is refused, 404/405/400 come from the router, identically on both targets | 0.64.0 | active | fixture | 0 |
| C-369 | a lambda's failure channel carries the error type its ! operands agree on; a String channel carries a typed error as its interpolation text, identically on both targets | 0.65.0 | active | fixture | 1 |
| C-370 | An http header that would split the request, or that the client manages, is refused with the same err on every lane | 0.66.0 | active | fixture | 0 |
+| C-371 | Float32 arithmetic rounds every result to binary32 on every leg | 0.66.0 | active | fixture | 1 |
+| C-372 | A Float32 displays as the shortest decimal that round-trips to the same binary32 | 0.66.0 | active | fixture | 1 |
diff --git a/docs/contracts/conformance.md b/docs/contracts/conformance.md
index 60fa3d91..fa195bbb 100644
--- a/docs/contracts/conformance.md
+++ b/docs/contracts/conformance.md
@@ -11,7 +11,7 @@
> (spec-coverage + evidence-class >= fixture for every active contract), so this
> page cannot legitimately contain an empty Fixtures cell.
-132 normative sections; 813 distinct executable fixtures.
+134 normative sections; 815 distinct executable fixtures.
| Section | Contracts | Fixtures (how CI runs each) |
|---------|-----------|------------------------------|
@@ -147,3 +147,5 @@
| ALS-T25 | C-223 | `spec/wasm_cross/matrix_pow_libm.almd` (byte-compare)
`spec/wasm_cross/matrix_softmax_fastexp.almd` (byte-compare)
`spec/wasm_cross/matrix_domain_edges.almd` (byte-compare) |
| ALS-T26 | C-359, C-360 | `spec/wasm_cross/datetime_pre_epoch.almd` (byte-compare)
`spec/stdlib/datetime_test.almd` (both-target test)
`spec/wasm_cross/datetime_parse_iso_strict.almd` (byte-compare) |
| ALS-T27 | C-361 | `spec/wasm_cross/url_authority_edges.almd` (byte-compare)
`spec/stdlib/url_test.almd` (both-target test) |
+| ALS-T28 | C-371 | `spec/wasm_cross/float32_arithmetic_rounds.almd` (byte-compare) |
+| ALS-T29 | C-372 | `spec/wasm_cross/float32_display_shortest.almd` (byte-compare) |
diff --git a/docs/contracts/contracts.toml b/docs/contracts/contracts.toml
index 28a37085..e6ace9d8 100644
--- a/docs/contracts/contracts.toml
+++ b/docs/contracts/contracts.toml
@@ -4527,3 +4527,23 @@ status = "active"
evidence = [
{ path = "spec/embedded_cross/http_header_refusal.almd", class = "fixture" },
]
+[[contract]]
+id = "C-371"
+spec = "ALS-T28"
+title = "Float32 arithmetic rounds every result to binary32 on every leg"
+statement = "Each Float32 `+`, `-`, `*`, `/`, `%`, `**` and unary `-` result is rounded to IEEE-754 binary32 (round-to-nearest-even) after that one operation, so every leg holds the value native's `f32` arithmetic computes: `1/3` widens to 0.3333333432674408, `2^24 + 1` is `2^24`, and ten additions of `0.1` give 1.0000001192092896. A leg that carries Float32 in an f64 slot rounds after every operation; before this, the wasm leg and the interpreter kept the unrounded f64 quotient (0.3333333333333333), which widening, a comparison or any later operation then observed. A conversion into Float32 (`float.to_float32`, `int.to_float32` and the sized `to_float32` twins) rounds once, a comparison reads the rounded values, and a bare float literal in a Float32 position narrows at birth (C-182). `a ** b` on Float32 is the f64 `**` of the widened operands (the vendored libm pow, C-026's one implementation) rounded once to binary32; before this, native did not build it at all (the f32 operands met the f64 pow, almide#3082). No `math.*` function takes a Float32; one reaches them only through an explicit `float32.to_float64`. spec/wasm_cross/float32_arithmetic_rounds.almd observes each operation through `float32.to_float64` and `float.to_string`, which show every bit of the binary32 value, and through comparisons whose answer flips when a leg does not round."
+since = "0.66.0"
+status = "active"
+evidence = [
+ { path = "spec/wasm_cross/float32_arithmetic_rounds.almd", class = "fixture" },
+]
+[[contract]]
+id = "C-372"
+spec = "ALS-T29"
+title = "A Float32 displays as the shortest decimal that round-trips to the same binary32"
+statement = "A Float32 prints as Rust `f32` Display does: the shortest positional decimal that parses back to the SAME binary32, so `0.1` prints `0.1`, not the widened f64's `0.10000000149011612`, and the f32 maximum prints 340282350000000000000000000000000000000. An exact tie between the two shortest candidates rounds the magnitude up (`2^-12` prints 0.00024414063, `2^20 + 0.25` prints 1048576.3). In interpolation and inside a list, record, option, tuple or variant payload an integral value drops its `.0` (C-011's rule: `${2.0f32}` is `2`), and `float32.to_string` keeps it (`2.0`); `-0` keeps its sign, and the non-finite values are `inf`, `-inf` and `NaN`. Before this, `float32.to_string` printed the widened f64's digits on every leg, and the wasm leg and the interpreter did the same for interpolation where native printed the f32 digits. spec/wasm_cross/float32_display_shortest.almd pins the value matrix (0.1, 1/3, a whole value, ±0, ±inf, NaN, the f32 maximum, the least normal, the least subnormal, a subnormal, both tie shapes) top-level in both forms and nested in each container."
+since = "0.66.0"
+status = "active"
+evidence = [
+ { path = "spec/wasm_cross/float32_display_shortest.almd", class = "fixture" },
+]
diff --git a/docs/specs/als/README.md b/docs/specs/als/README.md
index d73e4243..34c4fac3 100644
--- a/docs/specs/als/README.md
+++ b/docs/specs/als/README.md
@@ -153,7 +153,7 @@
| [ALS-S5](./strings.md#als-s5-split-の区切り規範) | split の区切り規範 | C-050 |
| [ALS-S6](./strings.md#als-s6-規模不変性) | 規模不変性 | C-074 |
-## text-and-numbers.md — 27 section(s)
+## text-and-numbers.md — 29 section(s)
| ID | Section | Contracts |
|----|---------|-----------|
@@ -184,5 +184,7 @@
| [ALS-T25](./text-and-numbers.md#als-t25-正準-fast-exp) | 正準 fast-exp | C-223 |
| [ALS-T26](./text-and-numbers.md#als-t26-datetime-の暦フィールドと-parse_iso-の文法) | datetime の暦フィールドと parse_iso の文法 | C-359, C-360 |
| [ALS-T27](./text-and-numbers.md#als-t27-urlparse-の-authority-規範) | url.parse の authority 規範 | C-361 |
+| [ALS-T28](./text-and-numbers.md#als-t28-float32-演算の-binary32-丸め) | Float32 演算の binary32 丸め | C-371 |
+| [ALS-T29](./text-and-numbers.md#als-t29-float32-の表示) | Float32 の表示 | C-372 |
-132 sections across 11 chapters.
+134 sections across 11 chapters.
diff --git a/docs/specs/als/text-and-numbers.md b/docs/specs/als/text-and-numbers.md
index be3e4acf..a4bb82cc 100644
--- a/docs/specs/als/text-and-numbers.md
+++ b/docs/specs/als/text-and-numbers.md
@@ -1,6 +1,6 @@
# ALS §T — Text and Number Semantics (normative)
-> Last updated: 2026-08-27
+> Last updated: 2026-09-30
> **Status**: normative. これらの節は実装から独立した**規範**であり、v0(native)と
> v1(MIR/wasm)の両実装がこの節に適合する義務を負う。適合の証拠は
@@ -531,3 +531,34 @@ host として読まずに名指しで拒否する: userinfo は
テスト: `spec/wasm_cross/url_authority_edges.almd`、
`spec/stdlib/url_test.almd`。Contracts: C-361。
+
+## ALS-T28 Float32 演算の binary32 丸め
+
+`Float32` の各演算(`+` `-` `*` `/` `%` `**`、単項 `-`)の結果は IEEE-754 **binary32**
+の最近接偶数丸めで **1 演算ごとに** f32 に丸まり、native の `f32` 演算と同じ値に
+なる。より広いスロット(f64)で `Float32` を運ぶ実装も、各演算の直後に binary32 へ
+丸めなければならない — 丸めない値は `float32.to_float64` による拡幅、比較、後続の
+演算のすべてで native と異なる。`Float32` への変換(`float.to_float32`、
+`int.to_float32` と sized 整数の `to_float32`)は 1 回丸める。比較(`==` `!=` `<`
+`<=` `>` `>=`)は丸めた binary32 の値を比べる。`Float32` 文脈の裸の float
+リテラルは生まれたときに binary32 へ狭まる(ALS-E3)。`math.*` は `Float` のみを
+受け取り、`Float32` は明示の拡幅(`float32.to_float64`)を経て渡る。`Float32` の
+`a ** b` は、拡幅した両辺の `Float` の `**`(ALS-T10 の単一の pow 実装)を 1 回
+binary32 へ丸めた値である。
+
+例: `1/3` は `0.3333333432674408` に拡幅され、`2^24 + 1` は `2^24` に丸まり、
+`0.1` を 10 回足した和は `1.0000001192092896`。
+
+テスト: `spec/wasm_cross/float32_arithmetic_rounds.almd`。Contracts: C-371。
+
+## ALS-T29 Float32 の表示
+
+`Float32` は、**同じ binary32 に往復する最短の十進表現**(Rust `f32` の Display、
+位置記法・指数なし)で表示される。`0.1` は `0.1` であり、拡幅した f64 の
+`0.10000000149011612` ではない。二つの最短候補がちょうど等距離なら大きさを
+切り上げる(`2^-12` は `0.00024414063`)。文字列補間とコンテナ内(リスト・
+レコード・Option・タプル・variant のペイロード)は整数値の `.0` を落とし
+(ALS-R2 / C-011 の規則)、`float32.to_string` は保つ。`-0` の符号を保ち、
+非有限値は `inf` / `-inf` / `NaN`。
+
+テスト: `spec/wasm_cross/float32_display_shortest.almd`。Contracts: C-372。
diff --git a/proofs/als-validation.toml b/proofs/als-validation.toml
index bfc91111..e6c985ee 100644
--- a/proofs/als-validation.toml
+++ b/proofs/als-validation.toml
@@ -1081,4 +1081,18 @@ hash = "sha256:be7520120410"
reviewed = "2026-09-22"
by = "O6lvl4 (via the Claude session; re-reviewed 2026-09-22 after the batch renumbering: the text now states the variable-width %Y that datetime.format shares with to_iso, and the T26/T27 calendar, parse_iso and url.parse rules, each checked against the fixture output recorded on native, structural wasm and incumbent wasm)"
independent = "no"
+verdict = "accurate"
+[[section]]
+id = "ALS-T28"
+hash = "sha256:89032e4530e1"
+reviewed = "2026-09-30"
+by = "O6lvl4 (via the Claude session; checked against the ref evaluator, native almide and the host f32 formatter over every f32 exponent and 3000 random patterns)"
+independent = "no"
+verdict = "accurate"
+[[section]]
+id = "ALS-T29"
+hash = "sha256:a1fec2de4a9a"
+reviewed = "2026-09-30"
+by = "O6lvl4 (via the Claude session; checked against the ref evaluator, native almide and the host f32 formatter over every f32 exponent and 3000 random patterns)"
+independent = "no"
verdict = "accurate"
\ No newline at end of file
diff --git a/proofs/contract-provenance.toml b/proofs/contract-provenance.toml
index 70684c38..8add1ddf 100644
--- a/proofs/contract-provenance.toml
+++ b/proofs/contract-provenance.toml
@@ -2786,3 +2786,17 @@ since = "0.66.0"
entry = "2026-09-28T14:56:42+09:00"
entry_commit = "e854238"
class = "requirements-first"
+
+[[contract]]
+id = "C-371"
+since = "0.66.0"
+entry = "2026-09-30T16:15:25+09:00"
+entry_commit = "6b3956e"
+class = "requirements-first"
+
+[[contract]]
+id = "C-372"
+since = "0.66.0"
+entry = "2026-09-30T16:15:25+09:00"
+entry_commit = "6b3956e"
+class = "requirements-first"
diff --git a/ref/src/eval.rs b/ref/src/eval.rs
index 660aa197..aed32ad9 100644
--- a/ref/src/eval.rs
+++ b/ref/src/eval.rs
@@ -196,6 +196,16 @@ fn snapshot_bytes(v: Value) -> Value {
/// canonicalize NaN results: every float operation that produces a NaN
/// observes as the single canonical quiet NaN (nan_canonical_observation)
+/// The binary32 value of a Float32, or of a Float literal narrowed to one
+/// (C-182: a literal takes its Float32 context).
+fn f32_of(v: &Value) -> f32 {
+ match v {
+ Value::Float32(g) => *g,
+ Value::Float(F64(f)) => *f as f32,
+ _ => f32::NAN,
+ }
+}
+
pub fn fnan(x: f64) -> f64 {
if x.is_nan() {
f64::from_bits(0x7FF8000000000000)
@@ -2446,6 +2456,7 @@ impl Interp {
match (op, v) {
(UnOp::Neg, Value::Int(n)) => Ok(Value::Int(n.wrapping_neg())),
(UnOp::Neg, Value::Float(f)) => Ok(Value::Float(F64(fnan(-f.0)))),
+ (UnOp::Neg, Value::Float32(g)) => Ok(Value::Float32(-g)),
(UnOp::Not, Value::Bool(b)) => Ok(Value::Bool(!b)),
(op, v) => Err(Flow::Fatal(format!("unary {op:?} on a {}", v.type_name()))),
}
@@ -2753,6 +2764,26 @@ impl Interp {
a.0, b.0,
)))))
}
+ // C-371: Float32 arithmetic is IEEE-754 binary32 — every + - * / % **
+ // result is rounded to f32 (round-to-nearest-even), never carried
+ // at f64 width. A bare float literal in a Float32 operation takes
+ // the Float32 type (it narrows at birth, C-182).
+ (Add | Sub | Mul | Div | Rem | Pow, Value::Float32(_), Value::Float(F64(f))) => {
+ self.binop(op, l.clone(), Value::Float32(*f as f32))
+ }
+ (Add | Sub | Mul | Div | Rem | Pow, Value::Float(F64(f)), Value::Float32(_)) => {
+ self.binop(op, Value::Float32(*f as f32), r.clone())
+ }
+ (Add, Value::Float32(a), Value::Float32(b)) => Ok(Value::Float32(a + b)),
+ (Sub, Value::Float32(a), Value::Float32(b)) => Ok(Value::Float32(a - b)),
+ (Mul, Value::Float32(a), Value::Float32(b)) => Ok(Value::Float32(a * b)),
+ (Div, Value::Float32(a), Value::Float32(b)) => Ok(Value::Float32(a / b)),
+ (Rem, Value::Float32(a), Value::Float32(b)) => Ok(Value::Float32(a % b)),
+ // `**` on Float32: the vendored libm pow (ALS-T10) of the widened
+ // operands, rounded once to binary32
+ (Pow, Value::Float32(a), Value::Float32(b)) => Ok(Value::Float32(
+ crate::libm::almide_rt_libm_pow(*a as f64, *b as f64) as f32,
+ )),
// C-180: sized-integer arithmetic wraps at the declared width;
// / and % are total per width (zero divisor and signed MIN/-1
// abort in the T6 form); unsigned widths divide/compare unsigned
@@ -2949,6 +2980,16 @@ impl Interp {
Some(o) => o,
None => return Ok(Value::Bool(false)),
},
+ // C-371: a Float32 compares its binary32 value; a literal
+ // operand narrows to Float32 first
+ (Value::Float32(_), Value::Float32(_))
+ | (Value::Float32(_), Value::Float(_))
+ | (Value::Float(_), Value::Float32(_)) => {
+ match f32_of(&l).partial_cmp(&f32_of(&r)) {
+ Some(o) => o,
+ None => return Ok(Value::Bool(false)),
+ }
+ }
(Value::Str(a), Value::Str(b)) => char_cmp(a, b),
// C-099: false < true
(Value::Bool(a), Value::Bool(b)) => a.cmp(b),
diff --git a/ref/src/fmtfloat.rs b/ref/src/fmtfloat.rs
index b795218a..7cf98934 100644
--- a/ref/src/fmtfloat.rs
+++ b/ref/src/fmtfloat.rs
@@ -151,6 +151,23 @@ pub fn shortest_digits(x: f64) -> (Vec, i32) {
shortest_core(m, e as i64, min_normal, x)
}
+/// Shortest digits that round-trip to the same BINARY32 (C-372): the same
+/// free-format core over the f32 significand and exponent — the rounding
+/// interval is the f32's, so `0.1f32` gives "1" at k = 0, not the widened
+/// f64's seventeen digits.
+pub fn shortest_digits_f32(g: f32) -> (Vec, i32) {
+ let bits = g.abs().to_bits();
+ let raw_exp = (bits >> 23) as i32;
+ let frac = (bits & ((1u32 << 23) - 1)) as u64;
+ let (m, e) = if raw_exp == 0 {
+ (frac, -149)
+ } else {
+ (frac | (1u64 << 23), raw_exp - 150)
+ };
+ let min_normal = m == (1u64 << 23) && e > -149;
+ shortest_core(m, e as i64, min_normal, g as f64)
+}
+
fn shortest_core(m: u64, e: i64, min_normal: bool, approx: f64) -> (Vec, i32) {
// scaled value R/S, boundaries M+ / M-
let (mut r, mut s, mut m_plus, mut m_minus);
@@ -258,13 +275,13 @@ fn shortest_core(m: u64, e: i64, min_normal: bool, approx: f64) -> (Vec, i32
break;
}
(true, true) => {
- // closest boundary decides; tie → even-ish: round up when 2r >= s
+ // closest candidate decides; an EXACT tie rounds the magnitude
+ // UP (2r >= s) — Rust Display's shortest mode, for binary64
+ // (1669760939663944.25 prints ...944.3) and binary32 alike
+ // (2^-12 prints 0.00024414063). An even-digit rule here read
+ // ...944.2 / 0.00024414062, which no implementation prints.
let two_r = r.shl(1);
- let up = match two_r.cmp(&s) {
- std::cmp::Ordering::Greater => true,
- std::cmp::Ordering::Less => false,
- std::cmp::Ordering::Equal => d % 2 == 1,
- };
+ let up = two_r.cmp(&s) != std::cmp::Ordering::Less;
digits.push(if up { d + 1 } else { d });
break;
}
@@ -377,6 +394,43 @@ pub fn display_form(f: F64) -> String {
}
}
+/// A Float32's text (C-372): Rust `f32` Display's shortest round-trip digits
+/// of the binary32 value, positional; `dot0` keeps the `.0` of an integral
+/// value (`float32.to_string`), off for interpolation / containers (C-011).
+fn f32_form(g: f32, dot0: bool) -> String {
+ if g.is_nan() {
+ return "NaN".into();
+ }
+ if g.is_infinite() {
+ return if g > 0.0 { "inf".into() } else { "-inf".into() };
+ }
+ if g == 0.0 {
+ let z = if dot0 { "0.0" } else { "0" };
+ return if g.is_sign_negative() {
+ format!("-{z}")
+ } else {
+ z.into()
+ };
+ }
+ let (digits, k) = shortest_digits_f32(g);
+ let body = positional(&digits, k, dot0);
+ if g < 0.0 {
+ format!("-{body}")
+ } else {
+ body
+ }
+}
+
+/// `float32.to_string` (C-372).
+pub fn to_string_form_f32(g: f32) -> String {
+ f32_form(g, true)
+}
+
+/// A Float32 in interpolation or inside a container (C-372).
+pub fn display_form_f32(g: f32) -> String {
+ f32_form(g, false)
+}
+
/// `float.to_fixed(x, n)` (ALS-T9): round-half-to-even on the EXACT binary
/// value — the decimal expansion of m·2^e is finite; we compare the true
/// remainder against one half of the last kept unit, exactly.
diff --git a/ref/src/stdlib_sized.rs b/ref/src/stdlib_sized.rs
index ec48142f..fa9463ad 100644
--- a/ref/src/stdlib_sized.rs
+++ b/ref/src/stdlib_sized.rs
@@ -59,6 +59,8 @@ pub const SIZED_FNS: &[&str] = &[
"float.to_float32_checked",
"float32.to_string",
"float.from_float32",
+ "float.to_float32",
+ "float32.to_float64",
"int.to_float32",
"int.to_float32_checked",
"int.max_value",
@@ -328,6 +330,22 @@ fn dispatch(it: &mut Interp, name: &str, args: Vec) -> Result {
+ // C-371: one rounding to binary32 (round-to-nearest-even)
+ arity(name, &args, 1)?;
+ let f = want_float(name, &args[0])?;
+ Ok(Value::Float32(f as f32))
+ }
+ "float32.to_float64" => {
+ arity(name, &args, 1)?;
+ match &args[0] {
+ Value::Float32(g) => Ok(Value::Float(F64(*g as f64))),
+ other => Err(Flow::Fatal(format!(
+ "{name}: expected Float32, got {}",
+ other.type_name()
+ ))),
+ }
+ }
"float.from_float32" => {
arity(name, &args, 1)?;
match &args[0] {
@@ -374,9 +392,9 @@ fn dispatch(it: &mut Interp, name: &str, args: Vec) -> Result {
arity(name, &args, 1)?;
match &args[0] {
- // measured on 0.59.1: the spelling is the f64 shortest form
- // of the WIDENED value (0.1f32 prints 0.10000000149011612)
- Value::Float32(g) => Ok(Value::str(&fmtfloat::to_string_form(F64(*g as f64)))),
+ // C-372: the binary32's own shortest digits (0.1f32 -> "0.1"),
+ // `.0` kept on an integral value like float.to_string
+ Value::Float32(g) => Ok(Value::str(&fmtfloat::to_string_form_f32(*g))),
other => Err(Flow::Fatal(format!(
"{name}: expected Float32, got {}",
other.type_name()
diff --git a/ref/src/value.rs b/ref/src/value.rs
index 15a7ba07..a7c9f699 100644
--- a/ref/src/value.rs
+++ b/ref/src/value.rs
@@ -321,6 +321,11 @@ pub fn values_eq(a: &Value, b: &Value) -> Option {
},
) if b1 == b2 && s1 == s2 => x == y,
(Value::Float(x), Value::Float(y)) => x.0 == y.0,
+ // C-371: binary32 equality; a Float literal operand narrows first
+ (Value::Float32(x), Value::Float32(y)) => x == y,
+ (Value::Float32(x), Value::Float(y)) | (Value::Float(y), Value::Float32(x)) => {
+ *x == y.0 as f32
+ }
(Value::Bool(x), Value::Bool(y)) => x == y,
(Value::Unit, Value::Unit) => true,
(Value::Str(x), Value::Str(y)) => x == y,
@@ -519,6 +524,8 @@ pub fn render(v: &Value) -> Option {
Some(match v {
Value::Int(n) => fmt_int(*n),
Value::Float(f) => crate::fmtfloat::display_form(*f),
+ // C-372: the shortest digits that round-trip to the SAME binary32
+ Value::Float32(g) => crate::fmtfloat::display_form_f32(*g),
Value::Bool(b) => {
if *b {
"true".to_string()
@@ -645,7 +652,6 @@ pub fn render(v: &Value) -> Option {
| Value::Path(_)
| Value::Matrix(_)
| Value::Time { .. }
- | Value::Float32(_)
| Value::Ptr(_) => return None,
})
}
diff --git a/ref/tests/fmt_oracle.rs b/ref/tests/fmt_oracle.rs
index 3daaa268..eafdc9ad 100644
--- a/ref/tests/fmt_oracle.rs
+++ b/ref/tests/fmt_oracle.rs
@@ -25,6 +25,7 @@ fn display_matches_host_shortest() {
(123456789.123456789f64).to_bits(),
(1e78f64).to_bits(),
(0.30000000000000004f64).to_bits(),
+ (1669760939663944.25f64).to_bits(), // an exact tie: rounds UP
];
let mut x: u64 = 0x9E3779B97F4A7C15;
for _ in 0..3000 {
@@ -92,6 +93,71 @@ fn display_matches_host_shortest() {
);
}
+/// C-372: a Float32 in interpolation prints the host's f32 Display — the
+/// shortest digits that round-trip to the same binary32 — over every biased
+/// exponent (with the significand edges, both signs: every power of two, the
+/// subnormal boundary, MIN/MAX, ±0, ±inf, NaN) and 3000 pseudo-random
+/// bit patterns.
+#[test]
+fn f32_display_matches_host_shortest() {
+ let exe = env!("CARGO_BIN_EXE_als-ref");
+ let dir = std::env::temp_dir().join(format!("alsref-fmt32-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let mut samples: Vec = Vec::new();
+ for e in 0..256u32 {
+ for m in [0u32, 1, 2, 3, 1 << 22, (1 << 23) - 2, (1 << 23) - 1] {
+ samples.push((e << 23) | m);
+ samples.push((1 << 31) | (e << 23) | m);
+ }
+ }
+ let mut x: u32 = 0x9E37_79B9;
+ for _ in 0..3000 {
+ x ^= x << 13;
+ x ^= x >> 17;
+ x ^= x << 5;
+ samples.push(x);
+ }
+ let mut prog = String::from("fn show(b: Int) -> Unit = {\n let g = float.to_float32(int.bits_to_float(b))\n println(\"${g}\")\n}\n\nfn main() -> Unit = {\n");
+ let mut expected = Vec::new();
+ for &bits in &samples {
+ let g = f32::from_bits(bits);
+ expected.push(format!("{g}"));
+ // the f64 carrying the same value (exact: every binary32 is a binary64)
+ prog.push_str(&format!(" show({})\n", (g as f64).to_bits() as i64));
+ }
+ prog.push_str("}\n");
+ let path = dir.join("probe32.almd");
+ std::fs::write(&path, prog).unwrap();
+ let out = Command::new(exe)
+ .args(["run", path.to_str().unwrap(), "--json"])
+ .output()
+ .unwrap();
+ let doc = String::from_utf8_lossy(&out.stdout).to_string();
+ let stdout = serde_lite::parse(&doc).stdout;
+ let got: Vec<&str> = stdout.lines().collect();
+ std::fs::remove_dir_all(&dir).ok();
+ assert_eq!(
+ got.len(),
+ expected.len(),
+ "line count; raw: {}",
+ &doc[..doc.len().min(300)]
+ );
+ let bad: Vec = got
+ .iter()
+ .zip(expected.iter())
+ .zip(samples.iter())
+ .filter(|((g, e), _)| g != e)
+ .map(|((g, e), b)| format!("bits={b:#010x} mine={g:?} host={e:?}"))
+ .collect();
+ assert!(
+ bad.is_empty(),
+ "{} of {} disagree:\n{}",
+ bad.len(),
+ expected.len(),
+ bad[..bad.len().min(10)].join("\n")
+ );
+}
+
/// just enough JSON reading for the protocol reply (no external crates)
mod serde_lite {
pub struct Doc {
diff --git a/spec/wasm_cross/float32_arithmetic_rounds.almd b/spec/wasm_cross/float32_arithmetic_rounds.almd
new file mode 100644
index 00000000..b5a7494f
--- /dev/null
+++ b/spec/wasm_cross/float32_arithmetic_rounds.almd
@@ -0,0 +1,40 @@
+// @contract: C-371
+// Float32 arithmetic is binary32 on every leg: each + - * / % ** and unary -
+// result is rounded to f32 (round-to-nearest-even), a conversion into Float32
+// rounds once, and a comparison reads the rounded values. A leg that carries
+// Float32 on a wider slot must round after every operation, or the widened
+// value (and every comparison or later operation on it) differs from
+// native's `f32`; `**` is the one f64 pow of the widened operands, rounded
+// once. Operands come from run-time values so no folder decides.
+fn widened(label: String, x: Float32) -> Unit = {
+ println("${label}: ${float.to_string(float32.to_float64(x))}")
+}
+
+effect fn main() -> Unit = {
+ let n = list.len([1, 2, 3])
+ let three: Float32 = int.to_float32(n)
+ let one: Float32 = three / three
+ let big: Float32 = int.to_float32(n * 5592405 + 1)
+ let tenth: Float32 = float.to_float32(0.1)
+ let fifth: Float32 = tenth + tenth
+ widened("1/3", one / three)
+ widened("0.1+0.2", tenth + fifth)
+ widened("1.1*1.1", (one + tenth) * (one + tenth))
+ widened("1-1e-8", one - float.to_float32(0.00000001))
+ widened("2^24+1", big + one)
+ widened("10%3.3", (three * three + one) % (three + three * tenth))
+ widened("-(1/3)", -(one / three))
+ widened("(1/3)**1.7", (one / three) ** (one + three * float.to_float32(0.23333333)))
+ widened("3**0.5", three ** (one / (one + one)))
+ widened("0.1f32", tenth)
+ widened("int 2^24+1", int.to_float32(16777217))
+ var s: Float32 = 0.0
+ for _ in 0..<10 {
+ s = s + tenth
+ }
+ widened("sum 10x0.1", s)
+ println("2^24+1 == 2^24: ${big + one == big}")
+ println("1+1e-8 > 1: ${one + float.to_float32(0.00000001) > one}")
+ println("0.1+0.2 == 0.3: ${tenth + fifth == float.to_float32(0.3)}")
+ println("1/3*3 < 1: ${one / three * three < one}")
+}
diff --git a/spec/wasm_cross/float32_display_shortest.almd b/spec/wasm_cross/float32_display_shortest.almd
new file mode 100644
index 00000000..bfcc3b2e
--- /dev/null
+++ b/spec/wasm_cross/float32_display_shortest.almd
@@ -0,0 +1,48 @@
+// @contract: C-372
+// A Float32 displays as the shortest decimal that round-trips to the SAME
+// binary32 (Rust `f32` Display): `0.1` stays `0.1`, never the widened f64's
+// `0.10000000149011612`. Interpolation drops the `.0` of an integral value and
+// `float32.to_string` keeps it (the C-011 rule), and the digits are the same
+// top-level and nested in a list, record, option, tuple or variant. An exact
+// tie between the two shortest candidates rounds the magnitude up (2^-12 is
+// 0.00024414063).
+type P = { x: Float32, n: Int }
+
+type Shape =
+ | Dot(Float32)
+ | Pair(Float32, Float32)
+
+fn show(label: String, x: Float32) -> Unit = {
+ println("${label}: ${x} | ${float32.to_string(x)}")
+}
+
+effect fn main() -> Unit = {
+ let n = list.len([1, 2, 3])
+ let three: Float32 = int.to_float32(n)
+ let one: Float32 = three / three
+ let zero: Float32 = three - three
+ let tenth: Float32 = float.to_float32(0.1)
+ show("0.1", tenth)
+ show("1/3", one / three)
+ show("2", one + one)
+ show("0", zero)
+ show("-0", -zero)
+ show("inf", one / zero)
+ show("-inf", -(one / zero))
+ show("nan", zero / zero)
+ show("max", float.to_float32(340282346638528859811704183484516925440.0))
+ show("min normal", float.to_float32(int.bits_to_float(4039728865751334912)))
+ show("min subnormal", float.to_float32(int.bits_to_float(3936146074321813504)))
+ show("subnormal", float.to_float32(int.bits_to_float(4008824136515190784)))
+ show("2^-12 tie", float.to_float32(0.000244140625))
+ show("2^20+0.25 tie", float.to_float32(1048576.25))
+ show("1e10", float.to_float32(10000000000.0))
+ show("123.456", float.to_float32(123.456))
+ let xs: List[Float32] = [tenth, one + one, one / three]
+ println("${xs}")
+ println("${P { x: tenth, n: 1 }}")
+ let o: Option[Float32] = some(tenth)
+ println("${o}")
+ println("${(tenth, one + one)}")
+ println("${Dot(tenth)} ${Pair(one / three, one + one)}")
+}