From e90c8fdd97d2213f1547dc179a8f5711fd4ee3e8 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 28 Jun 2026 20:23:39 +0000 Subject: [PATCH] =?UTF-8?q?vm-ext:=20rational=20cleanup=20=E2=80=94=20(/?= =?UTF-8?q?=20int=20int)=20returns=20float=20per=20spec,=20fix=20number=3F?= =?UTF-8?q?/exact=3F=20on=20Rational?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- hosts/ocaml/lib/sx_primitives.ml | 12 ++++++++++-- hosts/ocaml/lib/sx_vm.ml | 8 ++++---- spec/tests/test-rationals.sx | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/hosts/ocaml/lib/sx_primitives.ml b/hosts/ocaml/lib/sx_primitives.ml index d4c2295d..61c6f667 100644 --- a/hosts/ocaml/lib/sx_primitives.ml +++ b/hosts/ocaml/lib/sx_primitives.ml @@ -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 -> diff --git a/hosts/ocaml/lib/sx_vm.ml b/hosts/ocaml/lib/sx_vm.ml index 4763acae..3f9e2fa3 100644 --- a/hosts/ocaml/lib/sx_vm.ml +++ b/hosts/ocaml/lib/sx_vm.ml @@ -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) diff --git a/spec/tests/test-rationals.sx b/spec/tests/test-rationals.sx index 3f3150ae..28b6ae27 100644 --- a/spec/tests/test-rationals.sx +++ b/spec/tests/test-rationals.sx @@ -101,7 +101,7 @@ "rationals:float-contagion" (deftest "rational + float = float" (assert (float? (+ 1/3 0.5)))) (deftest "float + rational = float" (assert (float? (+ 0.5 1/3)))) - (deftest "rational * float = float" (assert (float? (* 1/2 2)))) + (deftest "rational * float = float" (assert (float? (* 1/2 2.0)))) (deftest "rational - float = float" (assert (float? (- 1/2 0.1))))) ;; --------------------------------------------------------------------------