diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml
index 40de7b49..1b2ba6bf 100644
--- a/hosts/ocaml/bin/sx_server.ml
+++ b/hosts/ocaml/bin/sx_server.ml
@@ -1160,6 +1160,22 @@ let sx_render_to_html expr env =
let _jit_warned : (string, bool) Hashtbl.t = Hashtbl.create 16
+(* Bisection aid: env-var-driven JIT filter. Lets us narrow which named
+ lambda the VM miscompiles without rebuilding.
+ SX_JIT_DENY=name1,name2 — never JIT these (substring match on exact name).
+ SX_JIT_ONLY=name1,name2 — JIT ONLY these (exact name); skip all others. *)
+let _jit_deny_set =
+ match Sys.getenv_opt "SX_JIT_DENY" with
+ | None | Some "" -> []
+ | Some s -> String.split_on_char ',' s |> List.map String.trim
+let _jit_only_set =
+ match Sys.getenv_opt "SX_JIT_ONLY" with
+ | None | Some "" -> []
+ | Some s -> String.split_on_char ',' s |> List.map String.trim
+let _jit_name_allowed name =
+ (not (List.mem name _jit_deny_set))
+ && (match _jit_only_set with [] -> true | only -> List.mem name only)
+
let rec make_vm_suspend_marker request saved_vm =
let d = Hashtbl.create 3 in
Hashtbl.replace d "__vm_suspended" (Bool true);
@@ -1178,6 +1194,8 @@ let rec make_vm_suspend_marker request saved_vm =
let register_jit_hook env =
Sx_runtime._jit_try_call_fn := Some (fun f args ->
match f with
+ | Lambda l when (match l.l_name with Some n -> not (_jit_name_allowed n) | None -> false) ->
+ None (* bisection filter excluded this name *)
| Lambda l ->
(match l.l_compiled with
| Some cl when not (Sx_vm.is_jit_failed cl) ->
@@ -4538,6 +4556,29 @@ let () =
else begin
(* Normal persistent server mode *)
let env = make_server_env () in
+ (* JIT needs the SX bytecode compiler (lib/compiler.sx) as its `compile`
+ binding — the native Sx_compiler.compile is an incomplete stub that
+ miscompiles parameters (emits arity-0 bytecode with params as
+ GLOBAL_GET). http/cli/site modes already load compiler.sx; the
+ persistent (epoch) serving mode must too before enabling the hook,
+ or every JIT-compiled function fails at runtime with "VM undefined:
+ " and falls back to CEK (with double-executed side effects). *)
+ (_import_env := Some env;
+ let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found ->
+ try Sys.getenv "SX_ROOT" with Not_found ->
+ if Sys.file_exists "/app/spec" then "/app" else Sys.getcwd () in
+ let lib_base = try Sys.getenv "SX_LIB_DIR" with Not_found ->
+ project_dir ^ "/lib" in
+ let compiler_path = lib_base ^ "/compiler.sx" in
+ let compiler_path =
+ if Sys.file_exists compiler_path then compiler_path
+ else if Sys.file_exists "lib/compiler.sx" then "lib/compiler.sx"
+ else compiler_path in
+ try load_library_file compiler_path; rebind_host_extensions env
+ with exn ->
+ Printf.eprintf "[sx-server] WARNING: failed to load compiler.sx for JIT (%s) — JIT disabled\n%!"
+ (Printexc.to_string exn));
+ register_jit_hook env;
send "(ready)";
(* Main command loop *)
try
diff --git a/hosts/ocaml/lib/sx_primitives.ml b/hosts/ocaml/lib/sx_primitives.ml
index bd25563c..5c438547 100644
--- a/hosts/ocaml/lib/sx_primitives.ml
+++ b/hosts/ocaml/lib/sx_primitives.ml
@@ -4153,6 +4153,19 @@ let () =
) Sx_types.jit_cache_queue;
Queue.clear Sx_types.jit_cache_queue;
Nil);
+ register "jit-exclude!" (fun args ->
+ (* Mark one or more function names as interpret-only (never JIT-compiled).
+ A guest interpreter calls this for its continuation-using dispatch core.
+ Accepts any number of string/symbol names. *)
+ List.iter (fun a ->
+ match a with
+ | String n | Symbol n -> Hashtbl.replace Sx_types.jit_excluded n ()
+ | _ -> ()) args;
+ Nil);
+ register "jit-excluded?" (fun args ->
+ match args with
+ | [String n] | [Symbol n] -> Bool (Hashtbl.mem Sx_types.jit_excluded n)
+ | _ -> Bool false);
register "jit-reset-counters!" (fun _args ->
Sx_types.jit_compiled_count := 0;
Sx_types.jit_skipped_count := 0;
diff --git a/hosts/ocaml/lib/sx_runtime.ml b/hosts/ocaml/lib/sx_runtime.ml
index 2d907457..0110a16b 100644
--- a/hosts/ocaml/lib/sx_runtime.ml
+++ b/hosts/ocaml/lib/sx_runtime.ml
@@ -17,11 +17,19 @@ let rec _fast_eq a b =
| Number x, Number y -> x = y
| Integer x, Number y -> float_of_int x = y
| Number x, Integer y -> x = float_of_int y
+ (* Exact rationals — must match the "=" primitive (safe_eq). Cross-multiply
+ for rational/rational; coerce for rational/int and rational/float. *)
+ | Rational (an, ad), Rational (bn, bd) -> an * bd = bn * ad
+ | Rational (n, d), Integer y -> n = y * d
+ | Integer x, Rational (n, d) -> x * d = n
+ | Rational (n, d), Number y -> float_of_int n /. float_of_int d = y
+ | Number x, Rational (n, d) -> x = float_of_int n /. float_of_int d
| Bool x, Bool y -> x = y
| Nil, Nil -> true
| Symbol x, Symbol y -> x = y
| Keyword x, Keyword y -> x = y
- | List la, List lb ->
+ | (List la | ListRef { contents = la }),
+ (List lb | ListRef { contents = lb }) ->
(try List.for_all2 _fast_eq la lb with Invalid_argument _ -> false)
| _ -> false
diff --git a/hosts/ocaml/lib/sx_types.ml b/hosts/ocaml/lib/sx_types.ml
index 3996a58d..40ffc230 100644
--- a/hosts/ocaml/lib/sx_types.ml
+++ b/hosts/ocaml/lib/sx_types.ml
@@ -470,6 +470,23 @@ let jit_compiled_count = ref 0
let jit_skipped_count = ref 0
let jit_threshold_skipped_count = ref 0
+(** Runtime, data-driven JIT exclusion set. Names added here are never
+ JIT-compiled — they run on the CEK interpreter instead.
+
+ This is how a guest interpreter declares its *interpret-only* functions:
+ those that capture or invoke first-class continuations (e.g. Smalltalk's
+ [call/cc]-based non-local return [^expr], or block escape). The stack VM
+ cannot transfer control through a CEK continuation, so a JIT-compiled
+ frame on the OCaml/VM stack between a [call/cc] and its [(k v)] invocation
+ would either fail at runtime or (worse) re-run with duplicated side
+ effects. Marking the dispatch core interpret-only keeps those functions on
+ the CEK while pure helpers still JIT.
+
+ Populated from SX via the [jit-exclude!] primitive (see sx_primitives).
+ Consulted in [Sx_vm.jit_compile_lambda], so it covers BOTH JIT entry
+ points: the CEK call hook and the in-VM tiered-compilation path. *)
+let jit_excluded : (string, unit) Hashtbl.t = Hashtbl.create 64
+
(** {2 JIT cache LRU eviction — Phase 2}
Once a lambda crosses the threshold, its [l_compiled] slot is filled.
diff --git a/hosts/ocaml/lib/sx_vm.ml b/hosts/ocaml/lib/sx_vm.ml
index 8aea6348..49f310bb 100644
--- a/hosts/ocaml/lib/sx_vm.ml
+++ b/hosts/ocaml/lib/sx_vm.ml
@@ -808,14 +808,31 @@ 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)
- | Integer x, Integer y -> Number (float_of_int x /. float_of_int 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. *)
| 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)
| _ -> (Hashtbl.find Sx_primitives.primitives "/") [a; b])
| 164 (* OP_EQ *) ->
let b = pop vm and a = pop vm in
- push vm (Bool (Sx_runtime._fast_eq a b))
+ (* Trivial scalar cases inline; everything else (Rational, Dict,
+ Record, Vector, ListRef, nested lists) delegates to the "="
+ primitive so VM equality matches CEK exactly. _fast_eq is a
+ stripped-down subset and must not be the source of truth here. *)
+ push vm (match a, b with
+ | Integer x, Integer y -> Bool (x = y)
+ | Number x, Number y -> Bool (x = y)
+ | Integer x, Number y -> Bool (float_of_int x = y)
+ | Number x, Integer y -> Bool (x = float_of_int y)
+ | String x, String y -> Bool (x = y)
+ | Bool x, Bool y -> Bool (x = y)
+ | Symbol x, Symbol y -> Bool (x = y)
+ | Keyword x, Keyword y -> Bool (x = y)
+ | Nil, Nil -> Bool true
+ | _ -> (Hashtbl.find Sx_primitives.primitives "=") [a; b])
| 165 (* OP_LT *) ->
let b = pop vm and a = pop vm in
push vm (match a, b with
@@ -1127,6 +1144,11 @@ let jit_compile_lambda (l : lambda) globals =
None
) else if _jit_is_broken_name fn_name then (
None
+ ) else if Hashtbl.mem Sx_types.jit_excluded fn_name then (
+ (* Guest-declared interpret-only function (continuation-using dispatch
+ core). Run on the CEK; the stack VM can't escape through a CEK
+ continuation. See Sx_types.jit_excluded. *)
+ None
) else
try
_jit_compiling := true;
diff --git a/lib/compiler.sx b/lib/compiler.sx
index 21510270..392115cf 100644
--- a/lib/compiler.sx
+++ b/lib/compiler.sx
@@ -783,11 +783,7 @@
(rest-clauses
(if (> (len flat-args) 2) (slice flat-args 2) (list))))
(if
- (or
- (and
- (= (type-of test) "keyword")
- (= (keyword-name test) "else"))
- (= test true))
+ (or (and (= (type-of test) "keyword") (= (keyword-name test) "else")) (and (= (type-of test) "symbol") (or (= (symbol-name test) "else") (= (symbol-name test) ":else"))) (= test true))
(compile-expr em body scope tail?)
(do
(compile-expr em test scope false)
@@ -828,11 +824,7 @@
(rest-clauses
(if (> (len clauses) 2) (slice clauses 2) (list))))
(if
- (or
- (and
- (= (type-of test) "keyword")
- (= (keyword-name test) "else"))
- (= test true))
+ (or (and (= (type-of test) "keyword") (= (keyword-name test) "else")) (and (= (type-of test) "symbol") (or (= (symbol-name test) "else") (= (symbol-name test) ":else"))) (= test true))
(do (emit-op em 5) (compile-expr em body scope tail?))
(do
(emit-op em 6)
@@ -1172,11 +1164,7 @@
(test (first clause))
(body (rest clause)))
(if
- (or
- (and
- (= (type-of test) "keyword")
- (= (keyword-name test) "else"))
- (= test true))
+ (or (and (= (type-of test) "keyword") (= (keyword-name test) "else")) (and (= (type-of test) "symbol") (or (= (symbol-name test) "else") (= (symbol-name test) ":else"))) (= test true))
(compile-begin em body scope tail?)
(do
(compile-expr em test scope false)
diff --git a/lib/smalltalk/eval.sx b/lib/smalltalk/eval.sx
index 9c049566..2e8cacaf 100644
--- a/lib/smalltalk/eval.sx
+++ b/lib/smalltalk/eval.sx
@@ -1475,3 +1475,22 @@
(get ast :temps)))
(smalltalk-eval-ast ast frame)))))))
(begin (dict-set! cell :active false) result)))))
+
+;; ── JIT interpret-only boundary ──────────────────────────────────────────
+;; The Smalltalk evaluator implements non-local return (^expr), block escape,
+;; and exception unwinding via first-class continuations (call/cc). A stack
+;; bytecode VM cannot transfer control through a CEK continuation, so any of
+;; these dispatch-core functions, if JIT-compiled, would be an un-escapable
+;; VM frame on the stack between a `call/cc` capture and its `(k v)` invocation
+;; — failing at runtime and (before this guard) re-running with duplicated
+;; side effects. Declaring them interpret-only keeps them on the CEK while the
+;; pure leaf helpers (parsing, ident/ivar lookup, formatting, predicates,
+;; arithmetic) still JIT. See Sx_types.jit_excluded / `jit-exclude!`.
+(jit-exclude!
+ "smalltalk-eval" "smalltalk-eval-program" "smalltalk-load"
+ "smalltalk-eval-ast" "st-eval-seq" "st-eval-send" "st-eval-send-dispatch"
+ "st-eval-cascade" "st-try-intrinsify" "st-send" "st-invoke" "st-dnu"
+ "st-super-send" "st-primitive-send" "st-num-send" "st-bool-send"
+ "st-string-send" "st-array-send" "st-nil-send" "st-class-side-send"
+ "st-block-apply" "st-block-dispatch" "st-block-while" "st-block-ensure"
+ "st-block-if-curtailed" "st-block-on-do" "st-block-value-selector?")
diff --git a/lib/smalltalk/tests/tokenize.sx b/lib/smalltalk/tests/tokenize.sx
index 23f5fdb3..c57ab604 100644
--- a/lib/smalltalk/tests/tokenize.sx
+++ b/lib/smalltalk/tests/tokenize.sx
@@ -360,3 +360,10 @@
{:type "number" :value 2}))
(list st-test-pass st-test-fail)
+
+;; The SUnit suite-runner `pharo-test-class` (defined in tests/pharo.sx and
+;; tests/ansi.sx) drives the interpret-only Smalltalk evaluator through
+;; smalltalk-eval-program in a loop and accumulates results via st-test
+;; (a side-effecting accumulator). Under JIT it can fail mid-loop and re-run
+;; via CEK, double-counting already-emitted rows. Keep it interpret-only.
+(jit-exclude! "pharo-test-class")
diff --git a/plans/jit-bytecode-correctness.md b/plans/jit-bytecode-correctness.md
new file mode 100644
index 00000000..c82ab1b0
--- /dev/null
+++ b/plans/jit-bytecode-correctness.md
@@ -0,0 +1,123 @@
+# JIT bytecode correctness — enable the JIT in serving mode
+
+> Kickoff handed over from the **host-on-sx** loop (2026-06-19). This is the
+> highest-leverage perf win on the platform.
+
+## Why this matters
+
+Every SX-on-SX subsystem runs **interpreted on the tree-walking CEK**: the
+Smalltalk runtime (→ content-on-sx rendering), and the guest languages
+(Datalog, Prolog, APL, Scheme, Haskell, Erlang, Maude). The lazy JIT
+(`register_jit_hook` → bytecode VM) would speed all of them up ~10–60×. It is
+currently **only installed in `--http` page-server mode**, not the epoch /
+`http-listen` serving mode — because it **miscompiles** these workloads.
+
+Concrete impact: the host serves a blog post (`content/html`, interpreted
+Smalltalk) in **~2 seconds per request**. With a correct JIT it should be tens
+of ms. Same slowdown applies to every guest-language-backed service.
+
+## Concrete repro (from the host loop)
+
+In `hosts/ocaml/bin/sx_server.ml`, the persistent server mode (`make_server_env`,
+~line 4871) does **not** call `register_jit_hook env` — only the `--http` mode
+(~line 4034) does. To reproduce the miscompile:
+
+1. Add `register_jit_hook env;` right after `let env = make_server_env () in` in
+ the persistent server-mode branch (~4871).
+2. Rebuild: `eval $(opam env --switch=5.2.0); dune build bin/sx_server.exe`.
+3. Run a Smalltalk/content-heavy suite, e.g. the host-on-sx conformance
+ (`bash /root/rose-ash-loops/host/lib/host/conformance.sh`, or any
+ content-on-sx suite). **With the hook ON, tests FAIL** — host-on-sx dropped to
+ `router 3/6, feed 4/11, relations 9/16, blog 4/11`. With the hook OFF: all green.
+
+So the JIT produces **wrong results** (the known "compiled compiler helpers loop
+on complex nested ASTs" — see memory `project_jit_bytecode_bug`).
+
+## Goal
+
+Make the JIT compile the Smalltalk-on-SX evaluator + guest-language evaluators
+**correctly**, so `register_jit_hook` can be enabled in serving mode with
+conformance **fully green**. Then enable it there.
+
+## Suggested approach
+
+- Minimal repro to bisect: render a `lib/content` doc via `content/html` with JIT
+ ON vs OFF, diff the output, find the first divergence.
+- Localize with the VM debugging tools (see CLAUDE.md): `(vm-trace ...)`,
+ `(bytecode-inspect ...)`, `(prim-check ...)`, `(deps-check ...)`.
+- Likely suspects: nested closures / TCO, dict construction, `st-send` dispatch
+ patterns, recursion through the Smalltalk method interpreter.
+
+## Pointers
+
+- `register_jit_hook` — `sx_server.ml` ~1493; JIT VM-suspend/resolve path ~1497–1514.
+- `hosts/ocaml/lib/sx_vm.ml` — the bytecode VM + compiler.
+- `plans/jit-cache-architecture.md`, `plans/jit-perf-regression.md`, `restore-jit-perf.sh`.
+- Memory: `project_jit_bytecode_bug.md` (plan ref `plans/reflective-rolling-treehouse.md`).
+- The shared `sx_server.exe` binary is used by ALL loops — coordinate before
+ changing VM semantics that could affect sibling conformance runs.
+
+---
+
+## Resolution (2026-06-19, loop loops/sx-vm-extensions)
+
+JIT is now enabled in the persistent (epoch) serving mode (`register_jit_hook`
+in `sx_server.ml`'s server-mode branch). Smalltalk conformance is **847/847 —
+identical to the no-JIT baseline** (no failures, no double-counted rows).
+Datalog conformance (a non-continuation guest) is **356/356** under JIT.
+
+Five distinct root causes were found and fixed (not one "miscompile"):
+
+1. **Serving mode never loaded `lib/compiler.sx`.** The JIT then used the
+ native `Sx_compiler.compile` stub, which emits arity-0 bytecode with every
+ parameter compiled as `GLOBAL_GET` → "VM undefined: " on the first
+ call of essentially every function. `http`/`cli`/`site` modes already load
+ `compiler.sx`; the epoch serving branch now does too (before the hook).
+ *Fix: `sx_server.ml` server-mode branch loads `lib/compiler.sx`.*
+
+2. **`compile-cond`/`compile-case-clauses`/`compile-guard-clauses` only treated
+ the keyword `:else` and `true` as the catch-all** — not the bare symbol
+ `else` that the CEK's `is-else-clause?` accepts. They emitted
+ `GLOBAL_GET "else"` → runtime "VM undefined: else".
+ *Fix: `lib/compiler.sx` — add the symbol-`else` case to all three.*
+
+3. **`OP_DIV` produced a float for non-divisible Integer/Integer** (`1/2` → 0.5)
+ instead of the exact `Rational` the `/` primitive returns → diverged from CEK
+ and broke equality vs rational results.
+ *Fix: `sx_vm.ml` — delegate non-divisible int/int to the `/` primitive.*
+
+4. **`OP_EQ` / `_fast_eq` lacked `Rational`/`ListRef` cases** that the real `=`
+ primitive's `safe_eq` has → `(= 1/2 1/2)` was false under JIT.
+ *Fix: `OP_EQ` delegates non-trivial types to the `=` primitive;
+ `_fast_eq` (also used by `prim_call "="`) gained rational + ListRef cases.*
+
+5. **Continuation-based control flow can't run in the stack VM.** Smalltalk's
+ non-local return (`^expr`), block escape, and exception unwinding use
+ `call/cc`; a JIT-compiled frame between a `call/cc` capture and its `(k v)`
+ invocation cannot transfer control and (via the hook's re-run-on-failure)
+ double-executes side effects.
+ *Fix: a general, data-driven exclusion set — `Sx_types.jit_excluded`,
+ populated from SX via the new `jit-exclude!` primitive, consulted in
+ `jit_compile_lambda` so it covers BOTH JIT entry points (CEK hook + in-VM
+ tiered path). `lib/smalltalk/eval.sx` self-declares its continuation-using
+ dispatch core interpret-only; pure helpers (parsing, lookup, formatting,
+ arithmetic) still JIT.* One SUnit suite-runner test helper
+ (`pharo-test-class`) miscompiles under JIT on a specific iteration and is
+ excluded in the test prelude (`tests/tokenize.sx`).
+
+### Known residual / follow-up
+- The hook still **re-runs a failed VM execution via CEK** (always yields the
+ correct result, but can duplicate side effects if a JIT'd function fails
+ mid-run after a side effect). `run_tests`'s hook instead propagates non-IO /
+ non-"VM undefined" exceptions. Adopting that propagate-don't-rerun semantics
+ in the serving hook would remove the double-execution class entirely, but it
+ surfaces genuine mid-run miscompiles as errors — so it must land together
+ with fixing/excluding any function that miscompiles mid-run (e.g.
+ `pharo-test-class`). Deferred to avoid changing shared VM/CEK semantics under
+ this loop.
+- Other continuation-heavy guests (Scheme, Erlang use `call/cc`) will need
+ their own `jit-exclude!` declarations for their dispatch cores; the mechanism
+ is in place. Non-continuation guests (Datalog/Prolog/Haskell/APL) JIT as-is.
+- A debug aid was added to the serving hook: `SX_JIT_DENY=name,...` /
+ `SX_JIT_ONLY=name,...` env vars to bisect which named lambda the VM
+ mishandles (hook-path only).