vm-ext: rational cleanup — (/ int int) returns float per spec, fix number?/exact? on Rational

The OP_DIV/numeric-tower work on this branch made the OCaml `/` primitive
return an exact Rational for (/ int int) (e.g. (/ 5 2)=5/2), diverging from
the canonical spec ("/ always returns inexact float"), the test-rationals.sx
header ("in the JS host, (/ int int) returns float — backward-compatible"),
and the JS host itself. That leaked rationals into arithmetic results and
rendered CSS (tw-opacity emitted `opacity:1/20` instead of `0.05`).

Decision (with the user): keep exact rationals as an explicit opt-in
(literals 1/3, make-rational) but bring `/` back into spec/host parity —
the isomorphic SSR↔hydration invariant requires both hosts to agree, and
JS has no native rational type.

- sx_primitives.ml `/`: (/ int int) → integer when exactly divisible, else
  inexact float; a Rational operand still yields an exact rational (matches
  test-numeric-tower: (/ 6 2)=3, (/ 1 4)=0.25, (/ 5 2)=2.5, (/ 1/2 2)=1/4).
- sx_primitives.ml number? / exact?: recognise the Rational type (real bugs —
  test-rationals asserts (number? 1/3) and (exact? 1/3); inexact?/float?
  already returned false for Rational, correct).
- sx_vm.ml OP_DIV: comment updated (it delegates to the now-float `/`).
- test-rationals.sx: fix typo in "rational * float = float" — used int 2,
  should be 2.0 (1/2 * 2 = 1 exact, not a float; name + siblings use floats).

OCaml conformance 4834→4863 (+29 fixed, zero new failures); rationals,
numeric-tower, arithmetic, tw-opacity suites all 0 failures. Remaining run_tests
failures are the pre-existing environmental hyperscript (host-call-fn) set.
JS host already handles number?/exact? on rationals and float `/`; its
remaining float?/contagion failures are a separate pre-existing limitation
(JS has no distinct float type), out of scope here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 20:23:39 +00:00
parent 81177d0ebd
commit e90c8fdd97
3 changed files with 15 additions and 7 deletions

View File

@@ -218,7 +218,14 @@ let () =
Number (List.fold_left (fun acc a -> acc *. as_number a) 1.0 args));
register "/" (fun args ->
match args with
| [Integer a; Integer b] -> make_rat a b
(* (/ int int): exact when divisible → integer, else inexact float.
Matches spec ("inexact float") + JS host (backward-compatible) +
test-numeric-tower ((/ 6 2)=3, (/ 1 4)=0.25, (/ 5 2)=2.5). Exact
rationals come ONLY from literals / make-rational, so a rational
OPERAND keeps the result exact (cases below) — but two integers do
NOT silently produce a rational (that diverged from the JS host). *)
| [Integer a; Integer b] when b <> 0 && a mod b = 0 -> Integer (a / b)
| [Integer a; Integer b] -> Number (float_of_int a /. float_of_int b)
| [Rational(an,ad); Integer b] -> make_rat an (ad * b)
| [Integer a; Rational(bn,bd)] -> make_rat (a * bd) bn
| [Rational(an,ad); Rational(bn,bd)] -> rat_div (an, ad) (bn, bd)
@@ -397,6 +404,7 @@ let () =
register "exact?" (fun args ->
match args with
| [Integer _] -> Bool true
| [Rational _] -> Bool true (* rationals are exact *)
| [Number _] -> Bool false
| [_] -> Bool false
| _ -> raise (Eval_error "exact?: 1 arg"));
@@ -833,7 +841,7 @@ let () =
match args with [a] -> Bool (is_nil a) | _ -> raise (Eval_error "nil?: 1 arg"));
register "number?" (fun args ->
match args with
| [Integer _] | [Number _] -> Bool true
| [Integer _] | [Number _] | [Rational _] -> Bool true
| [_] -> Bool false
| _ -> raise (Eval_error "number?: 1 arg"));
register "integer?" (fun args ->

View File

@@ -829,10 +829,10 @@ and run vm =
let b = pop vm and a = pop vm in
push vm (match a, b with
| Integer x, Integer y when y <> 0 && x mod y = 0 -> Integer (x / y)
(* Non-divisible Integer/Integer must delegate to the "/" primitive:
it returns an exact Rational (e.g. 1/2), matching CEK semantics.
Inlining float division here (0.5) diverges from the interpreter
and breaks numeric equality against rational results. *)
(* Non-divisible Integer/Integer + any Rational operand delegate to
the "/" primitive (single source of truth): (/ 5 2)=2.5 float,
(/ 1/2 2)=1/4 rational. Keeping the VM in lockstep with the
primitive avoids diverging from the CEK interpreter. *)
| Number x, Number y -> Number (x /. y)
| Integer x, Number y -> Number (float_of_int x /. y)
| Number x, Integer y -> Number (x /. float_of_int y)