Summary
On the Deno-ESM backend (and, by inspection, the other JS-family text backends), Int / Int is emitted as JavaScript /, which is IEEE-754 floating-point division. So integer division produces a non-integer:
# probe: pub fn idiv(a: Int, b: Int) -> Int = a / b; compiled --deno-esm
idiv(255, 16) === 15.9375 // expected 15
idiv(7, 2) === 3.5 // expected 3
Impact
stdlib/math.affine integer functions that use / return wrong (fractional) values on these backends: pow (exp / 2), sum_naturals (n * (n + 1) / 2), binomial, lcm (abs(a*b) / gcd(a,b)), div_floor. Any downstream .affine code doing integer division is affected.
Discovered while implementing stdlib/encoding.affine (#25), which sidesteps the bug by using bit-ops (>>, &) instead of /.
Root cause
The JS-family codegen is type-erased: division is ExprBinary (e1, OpDiv, e2) over the untyped AST (lib/ast.ml), and lib/codegen_deno.ml (gen_expr, the ExprBinary arm, ~:695) maps OpDiv -> "/" with no operand-type information, so it cannot distinguish Int/Int from Float/Float. The same OpDiv -> "/" arm is in lib/js_codegen.ml:177 (and the other OpDiv -> "/" text backends).
The wasm backend (lib/codegen.ml:396, OpDiv -> I32DivS) gets integer truncation for free because everything int-like is i32 — but that is the mirror-image latent bug (float division on the linear-memory wasm backend would be wrong; worth a separate check).
Correct semantics
AffineScript integer / truncates toward zero: the interpreter uses OCaml int / (lib/value.ml binop_int), wasm uses i32.div_s, constant-folding uses OCaml / (lib/opt.ml:21), and a separate div_floor exists in math.affine (confirming base / is truncation, not floor). The JS emit for integer division must therefore be Math.trunc(a / b).
⚠️ The naive fix is wrong
Blanket-wrapping every / in Math.trunc(...) fixes int but breaks float (3.0 / 2.0 → 1). The fix must be type-aware.
Recommended fix
Emit Math.trunc((a) / (b)) only when both operands are provably Int, otherwise keep /. Operand classification can be done locally in codegen_deno.ml by tracking variable types (function params + let bindings, which carry/can-infer types) plus int literals, int-arith, and -> Int calls; default unknown → / (so no float regression). This fixes the typed-Int-param cases (all the math.affine ones) safely. A more complete fix would thread the typechecker's inferred types (or a typed AST) into every backend.
Affected files
lib/codegen_deno.ml (~:695, OpDiv -> "/")
lib/js_codegen.ml:177
lib/codegen_node.ml and other JS/text backends sharing OpDiv -> "/"
- (cross-check)
lib/codegen.ml float division on the wasm backend
A scoped fix for the Deno-ESM backend (the one with tests/codegen-deno coverage) is in progress in PR #474.
Summary
On the Deno-ESM backend (and, by inspection, the other JS-family text backends),
Int / Intis emitted as JavaScript/, which is IEEE-754 floating-point division. So integer division produces a non-integer:Impact
stdlib/math.affineinteger functions that use/return wrong (fractional) values on these backends:pow(exp / 2),sum_naturals(n * (n + 1) / 2),binomial,lcm(abs(a*b) / gcd(a,b)),div_floor. Any downstream.affinecode doing integer division is affected.Discovered while implementing
stdlib/encoding.affine(#25), which sidesteps the bug by using bit-ops (>>,&) instead of/.Root cause
The JS-family codegen is type-erased: division is
ExprBinary (e1, OpDiv, e2)over the untyped AST (lib/ast.ml), andlib/codegen_deno.ml(gen_expr, theExprBinaryarm, ~:695) mapsOpDiv -> "/"with no operand-type information, so it cannot distinguishInt/IntfromFloat/Float. The sameOpDiv -> "/"arm is inlib/js_codegen.ml:177(and the otherOpDiv -> "/"text backends).The wasm backend (
lib/codegen.ml:396,OpDiv -> I32DivS) gets integer truncation for free because everything int-like isi32— but that is the mirror-image latent bug (float division on the linear-memory wasm backend would be wrong; worth a separate check).Correct semantics
AffineScript integer
/truncates toward zero: the interpreter uses OCamlint /(lib/value.mlbinop_int), wasm usesi32.div_s, constant-folding uses OCaml/(lib/opt.ml:21), and a separatediv_floorexists inmath.affine(confirming base/is truncation, not floor). The JS emit for integer division must therefore beMath.trunc(a / b).Blanket-wrapping every
/inMath.trunc(...)fixes int but breaks float (3.0 / 2.0→1). The fix must be type-aware.Recommended fix
Emit
Math.trunc((a) / (b))only when both operands are provablyInt, otherwise keep/. Operand classification can be done locally incodegen_deno.mlby tracking variable types (function params +letbindings, which carry/can-infer types) plus int literals, int-arith, and-> Intcalls; default unknown →/(so no float regression). This fixes the typed-Int-param cases (all themath.affineones) safely. A more complete fix would thread the typechecker's inferred types (or a typed AST) into every backend.Affected files
lib/codegen_deno.ml(~:695,OpDiv -> "/")lib/js_codegen.ml:177lib/codegen_node.mland other JS/text backends sharingOpDiv -> "/"lib/codegen.mlfloat division on the wasm backendA scoped fix for the Deno-ESM backend (the one with
tests/codegen-denocoverage) is in progress in PR #474.