Skip to content

codegen: integer / lowers to floating-point division on the JS-family backends #478

Description

@hyperpolymath

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.

Activity

  1. hyperpolymath commented on May 30, 2026

    @hyperpolymath
    OwnerAuthor

    The Deno-ESM backend half of this is fixed in PR #474.

    Approach (since codegen_deno.ml is type-erased): lower a provably-Int a / b to Math.trunc(a / b) and leave every other / as float division. A conservative expr_is_int classifier reports Int only when provable — int literals, Int-typed params, let/assignment-tracked Int locals (reset per function, updated in source order), integer-closed arithmetic, JS bitwise results, and calls to Int-returning fns/builtins. Unknown operands keep /, so float division is never silently truncated. x /= y over 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's pow / sum_naturals / binomial / lcm / div_floor now correct. New regression: tests/codegen-deno/int_div.{affine,harness.mjs}.

    Still open under this issue (not addressed by #474):

    • lib/js_codegen.ml and lib/codegen_node.ml share the same OpDiv -> "/" and need the equivalent fix.
    • Cross-check the mirror case: float division on the linear-memory wasm backend (lib/codegen.ml uses I32DivS unconditionally).

    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

  2. hyperpolymath commented on May 31, 2026

    @hyperpolymath
    OwnerAuthor

    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 … } where xs: [Int] — the loop variable wasn't seeded as Int, so the body's division stayed float (for x in [5] … x/2 → 2.5). Now StmtFor seeds the loop var from a provable Array<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 an Int operand via a new expr_is_int_array classifier.

    Both verified: for x in xs over [Int] → Math.trunc, over [Float] → plain / (no regression); xs[0] / 2 truncates. Regression cases added to tests/codegen-deno/int_div.

    Still out of scope here (tracked above): the sibling OpDiv -> "/" in js_codegen.ml / codegen_node.ml; record-field Int operands (e.g. p.count / 2) — expr_is_int doesn'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

  3. hyperpolymath commented on May 31, 2026

    @hyperpolymath
    OwnerAuthor

    Deno-ESM backend fix merged via PR #474 (fb7e855)

    The Deno-ESM half of this is now on main: integer Int / Int lowers to Math.trunc(a / b) (truncate toward zero, matching the interpreter + wasm i32.div_s); Float / Float stays plain /. A conservative expr_is_int / expr_is_int_array classifier covers int literals, Int params, let/assign-tracked locals, integer-closed arithmetic, JS bitwise, Int-returning calls, for-loop variables over Array<Int>, and xs[i] element reads. Regression test: tests/codegen-deno/int_div. Fixes stdlib/math.affine's integer functions on this backend.

    This issue stays OPEN for the remaining backends/cases (no Closes keyword used):

    • lib/js_codegen.ml and lib/codegen_node.ml share the same OpDiv -> "/" and need the equivalent fix.
    • Mirror check: float division on the linear-memory wasm backend (codegen.ml uses I32DivS unconditionally).
    • Record-field Int operands (p.count / 2) — expr_is_int doesn'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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions