Repository navigation
codegen: integer / lowers to floating-point division on the JS-family backends #478
Description
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:177lib/codegen_node.mland other JS/text backends sharingOpDiv -> "/"- (cross-check)
lib/codegen.mlfloat 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.
Activity
feat(stdlib): base64 codec in encoding.affine (stdlib #25) + smoke co…
fix(codegen-deno): truncate Int/Int division instead of float divide (#…
hyperpolymath commentedon May 30, 2026
The Deno-ESM backend half of this is fixed in PR #474.
Approach (since
codegen_deno.mlis type-erased): lower a provably-Inta / btoMath.trunc(a / b)and leave every other/as float division. A conservativeexpr_is_intclassifier reportsIntonly when provable — int literals,Int-typed params,let/assignment-trackedIntlocals (reset per function, updated in source order), integer-closed arithmetic, JS bitwise results, and calls toInt-returning fns/builtins. Unknown operands keep/, so float division is never silently truncated.x /= yover ints truncates too.Verified:
255 / 16 → 15,-7 / 2 → -3(toward zero, not floor),3.0 / 2.0 → 1.5(float preserved);stdlib/math.affine'spow/sum_naturals/binomial/lcm/div_floornow correct. New regression:tests/codegen-deno/int_div.{affine,harness.mjs}.Still open under this issue (not addressed by #474):
lib/js_codegen.mlandlib/codegen_node.mlshare the sameOpDiv -> "/"and need the equivalent fix.lib/codegen.mlusesI32DivSunconditionally).A complete fix would thread the typechecker's inferred types into the backends rather than re-deriving Int-ness locally per backend.
Generated by Claude Code
fix(codegen-deno): truncate Int/Int division instead of float divide (#…
fix(codegen-deno): truncate Int/Int division instead of float divide (#…
hyperpolymath commentedon May 31, 2026
Follow-up: a code-review of the initial Deno-ESM fix found two operand shapes it didn't yet recognize — both now handled in PR #474:
for x in xs { … x / k … }wherexs: [Int]— the loop variable wasn't seeded asInt, so the body's division stayed float (for x in [5] … x/2→2.5). NowStmtForseeds the loop var from a provableArray<Int>iterable (array literal of ints, or an[Int]-typed param), save/restored around the loop body.xs[i]indexed read from an[Int]value — now recognized as anIntoperand via a newexpr_is_int_arrayclassifier.Both verified:
for x in xsover[Int]→Math.trunc, over[Float]→ plain/(no regression);xs[0] / 2truncates. Regression cases added totests/codegen-deno/int_div.Still out of scope here (tracked above): the sibling
OpDiv -> "/"injs_codegen.ml/codegen_node.ml; record-fieldIntoperands (e.g.p.count / 2) —expr_is_intdoesn't yet carry struct-field types; and the wasm float-division mirror. A typed-AST thread to the backends would subsume all of these.Generated by Claude Code
feat(stdlib): base64 codec in encoding.affine (stdlib #25) + smoke co…
fix(codegen-deno): truncate Int/Int division instead of float divide (#…
docs(history): session handoff for bindings/stdlib closeout + #478 (PR …
feat(stdlib): base64 codec in encoding.affine (stdlib #25) + smoke co…
fix(codegen-deno): truncate Int/Int division instead of float divide (#…
docs(history): session handoff for bindings/stdlib closeout + #478 (PR …
Roadmap tracking + Zig-FFI doc (#19) + hex/base64 encoding (#25) + In…
hyperpolymath commentedon May 31, 2026
Deno-ESM backend fix merged via PR #474 (
fb7e855)The Deno-ESM half of this is now on
main: integerInt / Intlowers toMath.trunc(a / b)(truncate toward zero, matching the interpreter + wasmi32.div_s);Float / Floatstays plain/. A conservativeexpr_is_int/expr_is_int_arrayclassifier covers int literals,Intparams, let/assign-tracked locals, integer-closed arithmetic, JS bitwise,Int-returning calls, for-loop variables overArray<Int>, andxs[i]element reads. Regression test:tests/codegen-deno/int_div. Fixesstdlib/math.affine's integer functions on this backend.This issue stays OPEN for the remaining backends/cases (no
Closeskeyword used):lib/js_codegen.mlandlib/codegen_node.mlshare the sameOpDiv -> "/"and need the equivalent fix.codegen.mlusesI32DivSunconditionally).Intoperands (p.count / 2) —expr_is_intdoesn't yet carry struct-field types.A complete fix would thread the typechecker's inferred types into the backends rather than re-derive Int-ness per backend.
Generated by Claude Code
test(stdlib): batch 1 of algebraic-law property tests (6 cases)
test(stdlib): batch 1 of algebraic-law property tests (6 cases) — sta…
fix(bun-esm,typecheck): Int division in loops; generic externs used b…
fix(compiler): fixes found building a real multi-module TEA app on Bu…