Merge loops/erlang into architecture: Phases 7-10 (hot reload, FFI BIFs, BIF registry, VM opcode extension + erlang_ext); fixes cyclic-env identity hang

# Conflicts:
#	hosts/ocaml/bin/run_tests.ml
#	plans/sx-vm-opcode-extension.md
This commit is contained in:
2026-05-18 20:46:04 +00:00
17 changed files with 5912 additions and 110 deletions

View File

@@ -1599,6 +1599,213 @@ let run_foundation_tests () =
Printf.printf " FAIL: invocation_count: %s\n"
(match other with Some n -> string_of_int n | None -> "None"));
Printf.printf "\nSuite: extensions/erlang_ext (Phase 9h)\n";
(* Register the Erlang opcode namespace. Disjoint id range (200-217)
from test_ext (220/221) so they coexist. *)
Erlang_ext.register ();
(match prim [String "erlang.OP_PATTERN_TUPLE"] with
| Integer 222 ->
incr pass_count;
Printf.printf " PASS: extension-opcode-id erlang.OP_PATTERN_TUPLE = 222\n"
| other ->
incr fail_count;
Printf.printf " FAIL: erlang.OP_PATTERN_TUPLE: got %s\n"
(Sx_types.inspect other));
(match prim [String "erlang.OP_BIF_IS_TUPLE"] with
| Integer 239 ->
incr pass_count;
Printf.printf " PASS: extension-opcode-id erlang.OP_BIF_IS_TUPLE = 239\n"
| other ->
incr fail_count;
Printf.printf " FAIL: erlang.OP_BIF_IS_TUPLE: got %s\n"
(Sx_types.inspect other));
(match prim [String "erlang.OP_NONEXISTENT"] with
| Nil ->
incr pass_count;
Printf.printf " PASS: unknown erlang opcode -> nil\n"
| other ->
incr fail_count;
Printf.printf " FAIL: unknown erlang opcode: got %s\n"
(Sx_types.inspect other));
(* Phase 10b vertical slice: erlang.OP_BIF_LENGTH (230) is a REAL
handler. Build [CONST 0; OP_BIF_LENGTH; RETURN] with an Erlang
list [1,2,3] in the constant pool; expect Integer 3. Proves the
full path: bytecode -> Sx_vm extension fallthrough -> erlang_ext
handler -> correct stack result. *)
(let mk_dict kvs =
let h = Hashtbl.create 4 in
List.iter (fun (k, v) -> Hashtbl.replace h k v) kvs;
Sx_types.Dict h in
let er_nil = mk_dict [("tag", Sx_types.String "nil")] in
let er_cons hd tl =
mk_dict [("tag", Sx_types.String "cons");
("head", hd); ("tail", tl)] in
let lst = er_cons (Sx_types.Integer 1)
(er_cons (Sx_types.Integer 2)
(er_cons (Sx_types.Integer 3) er_nil)) in
let code = ({
vc_arity = 0; vc_rest_arity = -1; vc_locals = 0;
vc_bytecode = [| 1; 0; 0; 230; 50 |];
vc_constants = [| lst |];
vc_bytecode_list = None; vc_constants_list = None;
} : Sx_types.vm_code) in
let globals = Hashtbl.create 1 in
try
match Sx_vm.execute_module code globals with
| Integer 3 ->
incr pass_count;
Printf.printf " PASS: erlang.OP_BIF_LENGTH [1,2,3] -> 3 (real handler, end-to-end)\n"
| other ->
incr fail_count;
Printf.printf " FAIL: OP_BIF_LENGTH result: got %s\n"
(Sx_types.inspect other)
with exn ->
incr fail_count;
Printf.printf " FAIL: OP_BIF_LENGTH raised: %s\n"
(Printexc.to_string exn));
(* More real handlers (Phase 10b batch): build a list/tuple constant
and exercise HD/TL/TUPLE_SIZE/IS_* end-to-end through the VM. *)
(let mk_dict kvs =
let h = Hashtbl.create 4 in
List.iter (fun (k, v) -> Hashtbl.replace h k v) kvs;
Sx_types.Dict h in
let er_nil = mk_dict [("tag", Sx_types.String "nil")] in
let er_cons hd tl = mk_dict [("tag", Sx_types.String "cons");
("head", hd); ("tail", tl)] in
let er_tuple es = mk_dict [("tag", Sx_types.String "tuple");
("elements", Sx_types.List es)] in
let er_atom nm = mk_dict [("tag", Sx_types.String "atom");
("name", Sx_types.String nm)] in
let lst3 = er_cons (Sx_types.Integer 7)
(er_cons (Sx_types.Integer 8)
(er_cons (Sx_types.Integer 9) er_nil)) in
let tup3 = er_tuple [Sx_types.Integer 1; Sx_types.Integer 2;
Sx_types.Integer 3] in
let run consts bc =
let code = ({
vc_arity = 0; vc_rest_arity = -1; vc_locals = 0;
vc_bytecode = bc; vc_constants = consts;
vc_bytecode_list = None; vc_constants_list = None;
} : Sx_types.vm_code) in
Sx_vm.execute_module code (Hashtbl.create 1) in
let nm = function
| Sx_types.Dict d ->
(match Hashtbl.find_opt d "name" with
| Some (Sx_types.String s) -> s | _ -> "?")
| _ -> "?" in
let check label want got =
if got = want then begin
incr pass_count;
Printf.printf " PASS: %s\n" label
end else begin
incr fail_count;
Printf.printf " FAIL: %s: got %s\n" label (Sx_types.inspect got)
end in
(* HD [7,8,9] -> 7 *)
check "OP_BIF_HD [7,8,9] -> 7" (Sx_types.Integer 7)
(run [| lst3 |] [| 1;0;0; 231; 50 |]);
(* TL [7,8,9] -> [8,9], check its HD = 8 *)
check "OP_BIF_TL then HD -> 8" (Sx_types.Integer 8)
(run [| lst3 |] [| 1;0;0; 232; 231; 50 |]);
(* TUPLE_SIZE {1,2,3} -> 3 *)
check "OP_BIF_TUPLE_SIZE {1,2,3} -> 3" (Sx_types.Integer 3)
(run [| tup3 |] [| 1;0;0; 234; 50 |]);
(* IS_INTEGER 42 -> true ; IS_INTEGER [..] -> false *)
(match run [| Sx_types.Integer 42 |] [| 1;0;0; 236; 50 |] with
| v when nm v = "true" ->
incr pass_count; Printf.printf " PASS: OP_BIF_IS_INTEGER 42 -> true\n"
| v -> incr fail_count;
Printf.printf " FAIL: IS_INTEGER 42: got %s\n" (Sx_types.inspect v));
(match run [| lst3 |] [| 1;0;0; 236; 50 |] with
| v when nm v = "false" ->
incr pass_count; Printf.printf " PASS: OP_BIF_IS_INTEGER list -> false\n"
| v -> incr fail_count;
Printf.printf " FAIL: IS_INTEGER list: got %s\n" (Sx_types.inspect v));
(* IS_ATOM atom -> true ; IS_LIST nil -> true ; IS_TUPLE tuple -> true *)
(match run [| er_atom "ok" |] [| 1;0;0; 237; 50 |] with
| v when nm v = "true" ->
incr pass_count; Printf.printf " PASS: OP_BIF_IS_ATOM ok -> true\n"
| v -> incr fail_count;
Printf.printf " FAIL: IS_ATOM: got %s\n" (Sx_types.inspect v));
(match run [| er_nil |] [| 1;0;0; 238; 50 |] with
| v when nm v = "true" ->
incr pass_count; Printf.printf " PASS: OP_BIF_IS_LIST nil -> true\n"
| v -> incr fail_count;
Printf.printf " FAIL: IS_LIST nil: got %s\n" (Sx_types.inspect v));
(match run [| tup3 |] [| 1;0;0; 239; 50 |] with
| v when nm v = "true" ->
incr pass_count; Printf.printf " PASS: OP_BIF_IS_TUPLE {..} -> true\n"
| v -> incr fail_count;
Printf.printf " FAIL: IS_TUPLE: got %s\n" (Sx_types.inspect v));
(match run [| tup3 |] [| 1;0;0; 238; 50 |] with
| v when nm v = "false" ->
incr pass_count; Printf.printf " PASS: OP_BIF_IS_LIST tuple -> false\n"
| v -> incr fail_count;
Printf.printf " FAIL: IS_LIST tuple: got %s\n" (Sx_types.inspect v));
(* ELEMENT: element(2, {1,2,3}) -> 2. Calling convention: push
Index then Tuple; opcode pops Tuple (TOS) then Index. *)
check "OP_BIF_ELEMENT element(2,{1,2,3}) -> 2" (Sx_types.Integer 2)
(run [| Sx_types.Integer 2; tup3 |] [| 1;0;0; 1;1;0; 233; 50 |]);
check "OP_BIF_ELEMENT element(1,{1,2,3}) -> 1" (Sx_types.Integer 1)
(run [| Sx_types.Integer 1; tup3 |] [| 1;0;0; 1;1;0; 233; 50 |]);
(* ELEMENT out of range raises *)
(let raised =
(try ignore (run [| Sx_types.Integer 9; tup3 |]
[| 1;0;0; 1;1;0; 233; 50 |]); false
with Sx_types.Eval_error _ -> true) in
if raised then begin
incr pass_count;
Printf.printf " PASS: OP_BIF_ELEMENT out-of-range raises\n"
end else begin
incr fail_count;
Printf.printf " FAIL: OP_BIF_ELEMENT out-of-range should raise\n"
end);
(* LISTS_REVERSE [7,8,9] -> [9,8,7]; verify HD = 9 then HD of TL = 8 *)
check "OP_BIF_LISTS_REVERSE then HD -> 9" (Sx_types.Integer 9)
(run [| lst3 |] [| 1;0;0; 235; 231; 50 |]);
check "OP_BIF_LISTS_REVERSE then TL,HD -> 8" (Sx_types.Integer 8)
(run [| lst3 |] [| 1;0;0; 235; 232; 231; 50 |]);
(* reverse preserves length *)
check "OP_BIF_LISTS_REVERSE then LENGTH -> 3" (Sx_types.Integer 3)
(run [| lst3 |] [| 1;0;0; 235; 230; 50 |]));
(* A still-stubbed opcode (222 = erlang.OP_PATTERN_TUPLE) raises the
not-wired Eval_error — confirms the honest-failure path remains
for opcodes whose real handlers haven't landed. *)
(let globals = Hashtbl.create 1 in
try
ignore (Sx_vm.execute_module (make_bc_seq [| 222; 50 |]) globals);
incr fail_count;
Printf.printf " FAIL: erlang.OP_PATTERN_TUPLE dispatch should have raised\n"
with
| Sx_types.Eval_error msg
when (let needle = "not yet wired" in
let nl = String.length needle and ml = String.length msg in
let rec scan i =
if i + nl > ml then false
else if String.sub msg i nl = needle then true
else scan (i + 1)
in scan 0) ->
incr pass_count;
Printf.printf " PASS: erlang opcode dispatch raises not-wired error\n"
| exn ->
incr fail_count;
Printf.printf " FAIL: unexpected exn: %s\n" (Printexc.to_string exn));
(match Erlang_ext.dispatch_count () with
| Some n when n >= 1 ->
incr pass_count;
Printf.printf " PASS: erlang_ext state recorded %d dispatch(es)\n" n
| other ->
incr fail_count;
Printf.printf " FAIL: dispatch_count: %s\n"
(match other with Some n -> string_of_int n | None -> "None"));
Printf.printf "\nSuite: jit extension-opcode awareness\n";
let scan = Sx_vm.bytecode_uses_extension_opcodes in
let no_consts = [||] in

View File

@@ -18,6 +18,20 @@
open Sx_types
(* Force-link Sx_vm_extensions so its module-init runs: installs the
extension dispatch fallthrough and registers the `extension-opcode-id`
SX primitive. Without a reference here OCaml dead-code-eliminates the
module from sx_server.exe (it's only otherwise reached from run_tests),
leaving guest-language opcode extensions (Erlang Phase 9, etc.)
invisible to the runtime. The applied call is a harmless lookup. *)
let () = ignore (Sx_vm_extensions.id_of_name "")
(* Register the Erlang opcode extension (Phase 9h) so
`extension-opcode-id "erlang.OP_*"` resolves to the host ids the SX
stub dispatcher consults. Guarded: a double-register raises Failure,
which we swallow so a re-entered server process doesn't die. *)
let () = try Erlang_ext.register () with Failure _ -> ()
(* ====================================================================== *)
(* Font measurement via otfm — reads OpenType/TrueType font tables *)
(* ====================================================================== *)

View File

@@ -0,0 +1,278 @@
(** {1 [erlang_ext] — Erlang-on-SX VM opcode extension (Phase 9h)}
Registers the Erlang opcode namespace in [Sx_vm_extensions] so that
[extension-opcode-id "erlang.OP_*"] resolves to a stable id. The SX
stub dispatcher in [lib/erlang/vm/dispatcher.sx] consults these ids
(Phase 9i) and falls back to its own local ids when the host
extension is absent.
Opcode ids occupy 222-239 in the extension partition (200-247).
222+ is chosen to clear the test extensions' reserved ids
(test_reg 210/211, test_ext 220/221) so all three coexist in
run_tests; production sx_server only registers this one. Names
mirror the SX stub dispatcher exactly:
- 222 erlang.OP_PATTERN_TUPLE - 231 erlang.OP_BIF_HD
- 223 erlang.OP_PATTERN_LIST - 232 erlang.OP_BIF_TL
- 224 erlang.OP_PATTERN_BINARY - 233 erlang.OP_BIF_ELEMENT
- 225 erlang.OP_PERFORM - 234 erlang.OP_BIF_TUPLE_SIZE
- 226 erlang.OP_HANDLE - 235 erlang.OP_BIF_LISTS_REVERSE
- 227 erlang.OP_RECEIVE_SCAN - 236 erlang.OP_BIF_IS_INTEGER
- 228 erlang.OP_SPAWN - 237 erlang.OP_BIF_IS_ATOM
- 229 erlang.OP_SEND - 238 erlang.OP_BIF_IS_LIST
- 230 erlang.OP_BIF_LENGTH - 239 erlang.OP_BIF_IS_TUPLE
{2 Handler status}
The bytecode compiler does not yet emit these opcodes — Erlang
programs run through the general CEK path and the working
specialization path is the SX stub dispatcher. So every handler
here raises a descriptive [Eval_error] rather than silently
corrupting the VM stack. This keeps the extension honest: the
namespace is registered and disassembles by name, [extension-opcode-id]
works, but actually dispatching an opcode (which only happens once a
future phase teaches the compiler to emit them) fails loudly with a
pointer to the phase that will wire it. Real stack-machine handlers
land alongside compiler emission in a later phase. *)
open Sx_types
(** Per-instance state: invocation counter, purely to exercise the
[extension_state] machinery (mirrors [test_ext]). *)
type Sx_vm_extension.extension_state += ErlangExtState of {
mutable dispatched : int;
}
let not_wired name =
raise (Eval_error
(Printf.sprintf
"%s: bytecode emission not yet wired (Phase 9j) — \
Erlang runs via CEK; specialization path is the SX stub \
dispatcher in lib/erlang/vm/dispatcher.sx"
name))
module M : Sx_vm_extension.EXTENSION = struct
let name = "erlang"
let init () = ErlangExtState { dispatched = 0 }
let opcodes st =
let bump () = match st with
| ErlangExtState s -> s.dispatched <- s.dispatched + 1
| _ -> ()
in
let op id nm =
(id, nm, (fun (_vm : Sx_vm.vm) (_frame : Sx_vm.frame) ->
bump (); not_wired nm))
in
(* Phase 10b vertical slice: one REAL register-machine handler.
erlang.OP_BIF_LENGTH (230) — pops an Erlang list off the VM
stack and pushes its length. Proves the full path works:
extension-opcode-id -> bytecode -> Sx_vm dispatch fallthrough
-> this handler -> correct stack result. The remaining 17
opcodes still raise not_wired until their handlers + compiler
emission land. Erlang lists are tagged dicts:
nil = {"tag" -> String "nil"}
cons = {"tag" -> String "cons"; "head" -> v; "tail" -> v} *)
let er_tag d =
match Hashtbl.find_opt d "tag" with
| Some (String s) -> s | _ -> ""
in
let op_bif_length =
(230, "erlang.OP_BIF_LENGTH",
(fun (vm : Sx_vm.vm) (_frame : Sx_vm.frame) ->
bump ();
let v = Sx_vm.pop vm in
let rec walk acc node =
match node with
| Dict d ->
(match er_tag d with
| "nil" -> acc
| "cons" ->
(match Hashtbl.find_opt d "tail" with
| Some t -> walk (acc + 1) t
| None -> raise (Eval_error
"erlang.OP_BIF_LENGTH: cons cell without :tail"))
| _ -> raise (Eval_error
"erlang.OP_BIF_LENGTH: not a proper list"))
| _ -> raise (Eval_error
"erlang.OP_BIF_LENGTH: not a proper list")
in
Sx_vm.push vm (Integer (walk 0 v))))
in
(* Phase 10b — simple hot-BIF handlers. Erlang bool is the atom
{"tag"->"atom"; "name"->"true"|"false"}; mk_atom builds it. *)
let mk_atom nm =
let h = Hashtbl.create 2 in
Hashtbl.replace h "tag" (String "atom");
Hashtbl.replace h "name" (String nm);
Dict h
in
let er_bool b = mk_atom (if b then "true" else "false") in
let is_tag v t = match v with
| Dict d -> er_tag d = t
| _ -> false
in
let op_bif_hd =
(231, "erlang.OP_BIF_HD",
(fun (vm : Sx_vm.vm) _f ->
bump ();
match Sx_vm.pop vm with
| Dict d when er_tag d = "cons" ->
(match Hashtbl.find_opt d "head" with
| Some h -> Sx_vm.push vm h
| None -> raise (Eval_error "erlang.OP_BIF_HD: cons without :head"))
| _ -> raise (Eval_error "erlang.OP_BIF_HD: not a cons")))
in
let op_bif_tl =
(232, "erlang.OP_BIF_TL",
(fun (vm : Sx_vm.vm) _f ->
bump ();
match Sx_vm.pop vm with
| Dict d when er_tag d = "cons" ->
(match Hashtbl.find_opt d "tail" with
| Some t -> Sx_vm.push vm t
| None -> raise (Eval_error "erlang.OP_BIF_TL: cons without :tail"))
| _ -> raise (Eval_error "erlang.OP_BIF_TL: not a cons")))
in
let op_bif_tuple_size =
(234, "erlang.OP_BIF_TUPLE_SIZE",
(fun (vm : Sx_vm.vm) _f ->
bump ();
match Sx_vm.pop vm with
| Dict d when er_tag d = "tuple" ->
let n = match Hashtbl.find_opt d "elements" with
| Some (List es) -> List.length es
| Some (ListRef r) -> List.length !r
| _ -> raise (Eval_error
"erlang.OP_BIF_TUPLE_SIZE: tuple without :elements")
in
Sx_vm.push vm (Integer n)
| _ -> raise (Eval_error "erlang.OP_BIF_TUPLE_SIZE: not a tuple")))
in
let op_bif_is_integer =
(236, "erlang.OP_BIF_IS_INTEGER",
(fun (vm : Sx_vm.vm) _f ->
bump ();
let v = Sx_vm.pop vm in
Sx_vm.push vm (er_bool (match v with Integer _ -> true | _ -> false))))
in
let op_bif_is_atom =
(237, "erlang.OP_BIF_IS_ATOM",
(fun (vm : Sx_vm.vm) _f ->
bump ();
let v = Sx_vm.pop vm in
Sx_vm.push vm (er_bool (is_tag v "atom"))))
in
let op_bif_is_list =
(238, "erlang.OP_BIF_IS_LIST",
(fun (vm : Sx_vm.vm) _f ->
bump ();
let v = Sx_vm.pop vm in
Sx_vm.push vm (er_bool (is_tag v "cons" || is_tag v "nil"))))
in
let op_bif_is_tuple =
(239, "erlang.OP_BIF_IS_TUPLE",
(fun (vm : Sx_vm.vm) _f ->
bump ();
let v = Sx_vm.pop vm in
Sx_vm.push vm (er_bool (is_tag v "tuple"))))
in
(* element/2 and lists:reverse/1 — pure stack transforms (no
bytecode operands). Calling convention: args pushed left→right,
so element/2 stack is [.. Index Tuple] (Tuple on top). Erlang
element/2 is 1-indexed. *)
let op_bif_element =
(233, "erlang.OP_BIF_ELEMENT",
(fun (vm : Sx_vm.vm) _f ->
bump ();
let tup = Sx_vm.pop vm in
let idx = Sx_vm.pop vm in
match tup, idx with
| Dict d, Integer i when er_tag d = "tuple" ->
let es = match Hashtbl.find_opt d "elements" with
| Some (List es) -> es
| Some (ListRef r) -> !r
| _ -> raise (Eval_error
"erlang.OP_BIF_ELEMENT: tuple without :elements")
in
let n = List.length es in
if i < 1 || i > n then
raise (Eval_error
(Printf.sprintf
"erlang.OP_BIF_ELEMENT: index %d out of range 1..%d" i n))
else
Sx_vm.push vm (List.nth es (i - 1))
| _, Integer _ ->
raise (Eval_error "erlang.OP_BIF_ELEMENT: 2nd arg not a tuple")
| _ ->
raise (Eval_error "erlang.OP_BIF_ELEMENT: 1st arg not an integer")))
in
let op_bif_lists_reverse =
(235, "erlang.OP_BIF_LISTS_REVERSE",
(fun (vm : Sx_vm.vm) _f ->
bump ();
let v = Sx_vm.pop vm in
let mk_nil () =
let h = Hashtbl.create 1 in
Hashtbl.replace h "tag" (String "nil"); Dict h in
let mk_cons hd tl =
let h = Hashtbl.create 3 in
Hashtbl.replace h "tag" (String "cons");
Hashtbl.replace h "head" hd;
Hashtbl.replace h "tail" tl;
Dict h in
let rec rev acc node =
match node with
| Dict d ->
(match er_tag d with
| "nil" -> acc
| "cons" ->
let hd = match Hashtbl.find_opt d "head" with
| Some x -> x
| None -> raise (Eval_error
"erlang.OP_BIF_LISTS_REVERSE: cons without :head") in
let tl = match Hashtbl.find_opt d "tail" with
| Some x -> x
| None -> raise (Eval_error
"erlang.OP_BIF_LISTS_REVERSE: cons without :tail") in
rev (mk_cons hd acc) tl
| _ -> raise (Eval_error
"erlang.OP_BIF_LISTS_REVERSE: not a proper list"))
| _ -> raise (Eval_error
"erlang.OP_BIF_LISTS_REVERSE: not a proper list")
in
Sx_vm.push vm (rev (mk_nil ()) v)))
in
[
op 222 "erlang.OP_PATTERN_TUPLE";
op 223 "erlang.OP_PATTERN_LIST";
op 224 "erlang.OP_PATTERN_BINARY";
op 225 "erlang.OP_PERFORM";
op 226 "erlang.OP_HANDLE";
op 227 "erlang.OP_RECEIVE_SCAN";
op 228 "erlang.OP_SPAWN";
op 229 "erlang.OP_SEND";
op_bif_length;
op_bif_hd;
op_bif_tl;
op_bif_element;
op_bif_tuple_size;
op_bif_lists_reverse;
op_bif_is_integer;
op_bif_is_atom;
op_bif_is_list;
op_bif_is_tuple;
]
end
(** Register [erlang] in [Sx_vm_extensions]. Idempotent only by failing
loudly — calling twice raises [Failure]. sx_server calls this once
at startup. *)
let register () = Sx_vm_extensions.register (module M : Sx_vm_extension.EXTENSION)
(** Read the dispatch counter from the live registry state. [None] if
[register] hasn't run. *)
let dispatch_count () =
match Sx_vm_extensions.state_of_extension "erlang" with
| Some (ErlangExtState s) -> Some s.dispatched
| _ -> None

View File

@@ -33,3 +33,54 @@ least: persistent (path-copying) envs, an inline scheduler that
doesn't call/cc on the common path (msg-already-in-mailbox), and a
linked-list mailbox. None of those are in scope for the Phase 3
checkbox — captured here as the floor we're starting from.
## Phase 9 status (2026-05-14)
Specialized opcodes 9b9f landed as **stub dispatchers** in
`lib/erlang/vm/dispatcher.sx`: `OP_PATTERN_TUPLE/LIST/BINARY`,
`OP_PERFORM/HANDLE`, `OP_RECEIVE_SCAN`, `OP_SPAWN/SEND`, and ten
`OP_BIF_*` hot dispatch entries. Each opcode's handler is a thin
wrapper over the existing `er-match-*` / `er-bif-*` / runtime impls,
so **the perf numbers above are unchanged** — same per-hop cost, same
scheduler. The stubs exist to nail down opcode IDs, operand contracts,
and tests against `er-match!` parity *before* 9a (the OCaml
opcode-extension mechanism in `hosts/ocaml/evaluator/`) lands.
When 9a integrates and the bytecode compiler can emit these opcodes
at hot call sites, the real speedup story (~3000× ring throughput,
~1000× spawn) starts. Until then this file documents the
pre-integration ceiling. 72 vm-suite tests guard the stub correctness;
full conformance is **709/709** with the stub infrastructure loaded.
## Phase 9g — post-integration bench (2026-05-15)
9a (vm-ext mechanism), 9h (`erlang_ext.ml` registering `erlang.OP_*`
ids 222-239), and 9i (SX dispatcher consulting `extension-opcode-id`)
are now integrated and built into `hosts/ocaml/_build/default/bin/sx_server.exe`.
Re-ran the ring ladder on that binary:
| N (processes) | Hops | Wall-clock | Throughput |
|---|---|---|---|
| 10 | 10 | 938ms | 11 hops/s |
| 100 | 100 | 2772ms | 36 hops/s |
| 500 | 500 | 14190ms | 35 hops/s |
| 1000 | 1000 | 31814ms | 31 hops/s |
**Numbers are unchanged from the pre-integration baseline** — and that
is the expected, correct result. The opcode handlers (both the SX stub
dispatcher and the OCaml `erlang_ext` module) wrap the existing
`er-match-*` / `er-bif-*` / scheduler implementations 1-to-1, and the
**bytecode compiler does not yet emit `erlang.OP_*` opcodes**, so every
hop still goes through the general CEK path exactly as before. The
unchanged numbers therefore double as a no-regression check: the full
extension wiring (cherry-picked vm-ext A-E + force-link + erlang_ext +
SX bridge) added zero per-hop cost. Conformance **715/715** on this
binary.
The ~3000×/~1000× targets remain gated on a **future phase (Phase 10 —
bytecode emission)**: teach `lib/compiler.sx` (or the Erlang
transpiler) to emit `erlang.OP_PATTERN_TUPLE` etc. at hot call sites,
then give `erlang_ext.ml` real register-machine handlers instead of the
current honest not-wired raise. That is a substantial standalone phase,
tracked in `plans/erlang-on-sx.md`. 9g's deliverable — *honest
measurement + recorded numbers on the integrated binary* — is complete.

View File

@@ -36,6 +36,8 @@ SUITES=(
"bank|er-bank-test-pass|er-bank-test-count"
"echo|er-echo-test-pass|er-echo-test-count"
"fib|er-fib-test-pass|er-fib-test-count"
"ffi|er-ffi-test-pass|er-ffi-test-count"
"vm|er-vm-test-pass|er-vm-test-count"
)
cat > "$TMPFILE" << 'EPOCHS'
@@ -56,6 +58,9 @@ cat > "$TMPFILE" << 'EPOCHS'
(load "lib/erlang/tests/programs/bank.sx")
(load "lib/erlang/tests/programs/echo.sx")
(load "lib/erlang/tests/programs/fib_server.sx")
(load "lib/erlang/vm/dispatcher.sx")
(load "lib/erlang/tests/ffi.sx")
(load "lib/erlang/tests/vm.sx")
(epoch 100)
(eval "(list er-test-pass er-test-count)")
(epoch 101)
@@ -74,6 +79,10 @@ cat > "$TMPFILE" << 'EPOCHS'
(eval "(list er-echo-test-pass er-echo-test-count)")
(epoch 108)
(eval "(list er-fib-test-pass er-fib-test-count)")
(epoch 109)
(eval "(list er-ffi-test-pass er-ffi-test-count)")
(epoch 110)
(eval "(list er-vm-test-pass er-vm-test-count)")
EPOCHS
timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1

View File

@@ -853,6 +853,112 @@
(define er-modules-get (fn () (nth er-modules 0)))
(define er-modules-reset! (fn () (set-nth! er-modules 0 {})))
(define er-mk-module-slot
(fn (mod-env old-env version)
{:current mod-env :old old-env :version version :tag "module"}))
(define er-module-current-env (fn (slot) (get slot :current)))
(define er-module-old-env (fn (slot) (get slot :old)))
(define er-module-version (fn (slot) (get slot :version)))
;; ── FFI BIF registry (Phase 8) ───────────────────────────────────
;; Global dict from "Module/Name/Arity" key to {:module :name :arity :fn :pure?}.
;; Replaces the giant cond chain in transpile.sx#er-apply-remote-bif over time —
;; Phase 8 BIFs (crypto / cid / file / httpc / sqlite) all register here.
(define er-bif-registry (list {}))
(define er-bif-registry-get (fn () (nth er-bif-registry 0)))
(define er-bif-registry-reset! (fn () (set-nth! er-bif-registry 0 {})))
(define er-bif-key
(fn (module name arity)
(str module "/" name "/" arity)))
(define er-register-bif!
(fn (module name arity sx-fn)
(dict-set! (er-bif-registry-get) (er-bif-key module name arity)
{:module module :name name :arity arity :fn sx-fn :pure? false})
(er-mk-atom "ok")))
(define er-register-pure-bif!
(fn (module name arity sx-fn)
(dict-set! (er-bif-registry-get) (er-bif-key module name arity)
{:module module :name name :arity arity :fn sx-fn :pure? true})
(er-mk-atom "ok")))
(define er-lookup-bif
(fn (module name arity)
(let ((reg (er-bif-registry-get)) (k (er-bif-key module name arity)))
(if (dict-has? reg k) (get reg k) nil))))
(define er-list-bifs
(fn () (keys (er-bif-registry-get))))
;; ── term marshalling (Phase 8) ───────────────────────────────────
;; Bridge Erlang term values (tagged dicts) and SX-native values for
;; FFI BIFs to call out into platform primitives. Conversions:
;;
;; Erlang SX-native
;; ───────────────────────── ────────────────
;; atom {:tag "atom" :name S} ↔ symbol (make-symbol S)
;; nil {:tag "nil"} ↔ '()
;; cons {:tag "cons" :head :tail} → list of marshalled elements
;; tuple {:tag "tuple" :elements} → list of marshalled elements
;; binary {:tag "binary" :bytes} ↔ SX string
;; integer / float / boolean ↔ passthrough
;; SX string on the way back → binary
;;
;; Pids, refs, funs pass through unchanged — they have no SX-native
;; equivalent and are opaque to FFI primitives.
(define er-cons-to-sx-list
(fn (v)
(cond
(er-nil? v) (list)
(er-cons? v)
(let ((tail (er-cons-to-sx-list (get v :tail)))
(head (er-to-sx (get v :head))))
(let ((out (list head)))
(for-each
(fn (i) (append! out (nth tail i)))
(range 0 (len tail)))
out))
:else (list v))))
(define er-to-sx
(fn (v)
(cond
(er-atom? v) (make-symbol (get v :name))
(er-nil? v) (list)
(er-cons? v) (er-cons-to-sx-list v)
(er-tuple? v)
(let ((out (list)) (es (get v :elements)))
(for-each
(fn (i) (append! out (er-to-sx (nth es i))))
(range 0 (len es)))
out)
(er-binary? v) (list->string (map integer->char (get v :bytes)))
:else v)))
(define er-of-sx
(fn (v)
(let ((ty (type-of v)))
(cond
(= ty "symbol") (er-mk-atom (str v))
(= ty "string") (er-mk-binary (map char->integer (string->list v)))
(= ty "list")
(let ((out (er-mk-nil)))
(for-each
(fn (i)
(set! out
(er-mk-cons (er-of-sx (nth v (- (- (len v) 1) i))) out)))
(range 0 (len v)))
out)
(= ty "nil") (er-mk-nil)
:else v))))
;; Load an Erlang module declaration. Source must start with
;; `-module(Name).` and contain function definitions. Functions
;; sharing a name (different arities) get their clauses concatenated
@@ -897,7 +1003,15 @@
((all-clauses (get by-name k)))
(er-env-bind! mod-env k (er-mk-fun all-clauses mod-env))))
(keys by-name))
(dict-set! (er-modules-get) mod-name mod-env)
(let ((registry (er-modules-get)))
(if (dict-has? registry mod-name)
(let ((existing-slot (get registry mod-name)))
(dict-set! registry mod-name
(er-mk-module-slot mod-env
(er-module-current-env existing-slot)
(+ (er-module-version existing-slot) 1))))
(dict-set! registry mod-name
(er-mk-module-slot mod-env nil 1))))
(er-mk-atom mod-name)))))
(define
@@ -905,7 +1019,7 @@
(fn
(mod name vs)
(let
((mod-env (get (er-modules-get) mod)))
((mod-env (er-module-current-env (get (er-modules-get) mod))))
(if
(not (dict-has? mod-env name))
(raise
@@ -1189,16 +1303,170 @@
:else (er-mk-atom "undefined")))
:else (error "Erlang: ets:info: arity"))))
(define
er-apply-ets-bif
(fn
(name vs)
(cond
(= name "new") (er-bif-ets-new vs)
(= name "insert") (er-bif-ets-insert vs)
(= name "lookup") (er-bif-ets-lookup vs)
(= name "delete") (er-bif-ets-delete vs)
(= name "tab2list") (er-bif-ets-tab2list vs)
(= name "info") (er-bif-ets-info vs)
:else (error
(str "Erlang: undefined 'ets:" name "/" (len vs) "'")))))
;; ── file module (Phase 8 FFI) ────────────────────────────────────
;; Synchronous file IO. Filenames must be SX strings (or Erlang
;; binaries/char-code lists coercible to strings via er-source-to-string).
;; Returns `{ok, Binary}` / `ok` on success, `{error, Reason}` on failure
;; where Reason is one of `enoent`, `eacces`, `enotdir`, `posix_error`.
(define er-classify-file-error
(fn (msg)
(let ((s (str msg)))
(cond
(string-contains? s "No such") (er-mk-atom "enoent")
(string-contains? s "Permission denied") (er-mk-atom "eacces")
(string-contains? s "Not a directory") (er-mk-atom "enotdir")
(string-contains? s "Is a directory") (er-mk-atom "eisdir")
:else (er-mk-atom "posix_error")))))
(define er-bif-file-read-file
(fn (vs)
(let ((path (er-source-to-string (nth vs 0))))
(cond
(= path nil)
(er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg")))
:else
(let ((res (list nil)) (err (list nil)))
(guard (c (:else (set-nth! err 0 c)))
(set-nth! res 0 (file-read path)))
(cond
(not (= (nth err 0) nil))
(er-mk-tuple (list (er-mk-atom "error")
(er-classify-file-error (nth err 0))))
:else
(er-mk-tuple (list (er-mk-atom "ok")
(er-mk-binary (map char->integer (string->list (nth res 0))))))))))))
(define er-bif-file-write-file
(fn (vs)
(let ((path (er-source-to-string (nth vs 0)))
(data (er-source-to-string (nth vs 1))))
(cond
(or (= path nil) (= data nil))
(er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg")))
:else
(let ((err (list nil)))
(guard (c (:else (set-nth! err 0 c)))
(file-write path data))
(cond
(not (= (nth err 0) nil))
(er-mk-tuple (list (er-mk-atom "error")
(er-classify-file-error (nth err 0))))
:else (er-mk-atom "ok")))))))
(define er-bif-file-delete
(fn (vs)
(let ((path (er-source-to-string (nth vs 0))))
(cond
(= path nil)
(er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg")))
:else
(let ((err (list nil)))
(guard (c (:else (set-nth! err 0 c)))
(file-delete path))
(cond
(not (= (nth err 0) nil))
(er-mk-tuple (list (er-mk-atom "error")
(er-classify-file-error (nth err 0))))
:else (er-mk-atom "ok")))))))
;; ── builtin BIF registrations (Phase 8 migration) ────────────────
;; Populates `er-bif-registry` with every existing built-in BIF. Each
;; entry is keyed by "Module/Name/Arity"; multi-arity BIFs register
;; once per arity. Called eagerly at the end of runtime.sx so the
;; registry is ready before any erlang-eval-ast call.
(define er-register-builtin-bifs!
(fn ()
;; erlang module — type predicates (all pure)
(er-register-pure-bif! "erlang" "is_integer" 1 er-bif-is-integer)
(er-register-pure-bif! "erlang" "is_atom" 1 er-bif-is-atom)
(er-register-pure-bif! "erlang" "is_list" 1 er-bif-is-list)
(er-register-pure-bif! "erlang" "is_tuple" 1 er-bif-is-tuple)
(er-register-pure-bif! "erlang" "is_number" 1 er-bif-is-number)
(er-register-pure-bif! "erlang" "is_float" 1 er-bif-is-float)
(er-register-pure-bif! "erlang" "is_boolean" 1 er-bif-is-boolean)
(er-register-pure-bif! "erlang" "is_pid" 1 er-bif-is-pid)
(er-register-pure-bif! "erlang" "is_reference" 1 er-bif-is-reference)
(er-register-pure-bif! "erlang" "is_binary" 1 er-bif-is-binary)
(er-register-pure-bif! "erlang" "is_function" 1 er-bif-is-function)
(er-register-pure-bif! "erlang" "is_function" 2 er-bif-is-function)
;; erlang module — pure data ops
(er-register-pure-bif! "erlang" "length" 1 er-bif-length)
(er-register-pure-bif! "erlang" "hd" 1 er-bif-hd)
(er-register-pure-bif! "erlang" "tl" 1 er-bif-tl)
(er-register-pure-bif! "erlang" "element" 2 er-bif-element)
(er-register-pure-bif! "erlang" "tuple_size" 1 er-bif-tuple-size)
(er-register-pure-bif! "erlang" "byte_size" 1 er-bif-byte-size)
(er-register-pure-bif! "erlang" "atom_to_list" 1 er-bif-atom-to-list)
(er-register-pure-bif! "erlang" "list_to_atom" 1 er-bif-list-to-atom)
(er-register-pure-bif! "erlang" "abs" 1 er-bif-abs)
(er-register-pure-bif! "erlang" "min" 2 er-bif-min)
(er-register-pure-bif! "erlang" "max" 2 er-bif-max)
(er-register-pure-bif! "erlang" "tuple_to_list" 1 er-bif-tuple-to-list)
(er-register-pure-bif! "erlang" "list_to_tuple" 1 er-bif-list-to-tuple)
(er-register-pure-bif! "erlang" "integer_to_list" 1 er-bif-integer-to-list)
(er-register-pure-bif! "erlang" "list_to_integer" 1 er-bif-list-to-integer)
;; erlang module — process / runtime (side-effecting)
(er-register-bif! "erlang" "self" 0 er-bif-self)
(er-register-bif! "erlang" "spawn" 1 er-bif-spawn)
(er-register-bif! "erlang" "spawn" 3 er-bif-spawn)
(er-register-bif! "erlang" "exit" 1 er-bif-exit)
(er-register-bif! "erlang" "exit" 2 er-bif-exit)
(er-register-bif! "erlang" "make_ref" 0 er-bif-make-ref)
(er-register-bif! "erlang" "link" 1 er-bif-link)
(er-register-bif! "erlang" "unlink" 1 er-bif-unlink)
(er-register-bif! "erlang" "monitor" 2 er-bif-monitor)
(er-register-bif! "erlang" "demonitor" 1 er-bif-demonitor)
(er-register-bif! "erlang" "process_flag" 2 er-bif-process-flag)
(er-register-bif! "erlang" "register" 2 er-bif-register)
(er-register-bif! "erlang" "unregister" 1 er-bif-unregister)
(er-register-bif! "erlang" "whereis" 1 er-bif-whereis)
(er-register-bif! "erlang" "registered" 0 er-bif-registered)
;; erlang module — exception raising (modelled as side-effecting)
(er-register-bif! "erlang" "throw" 1
(fn (vs) (raise (er-mk-throw-marker (er-bif-arg1 vs "throw")))))
(er-register-bif! "erlang" "error" 1
(fn (vs) (raise (er-mk-error-marker (er-bif-arg1 vs "error")))))
;; lists module — all pure
(er-register-pure-bif! "lists" "reverse" 1 er-bif-lists-reverse)
(er-register-pure-bif! "lists" "map" 2 er-bif-lists-map)
(er-register-pure-bif! "lists" "foldl" 3 er-bif-lists-foldl)
(er-register-pure-bif! "lists" "seq" 2 er-bif-lists-seq)
(er-register-pure-bif! "lists" "seq" 3 er-bif-lists-seq)
(er-register-pure-bif! "lists" "sum" 1 er-bif-lists-sum)
(er-register-pure-bif! "lists" "nth" 2 er-bif-lists-nth)
(er-register-pure-bif! "lists" "last" 1 er-bif-lists-last)
(er-register-pure-bif! "lists" "member" 2 er-bif-lists-member)
(er-register-pure-bif! "lists" "append" 2 er-bif-lists-append)
(er-register-pure-bif! "lists" "filter" 2 er-bif-lists-filter)
(er-register-pure-bif! "lists" "any" 2 er-bif-lists-any)
(er-register-pure-bif! "lists" "all" 2 er-bif-lists-all)
(er-register-pure-bif! "lists" "duplicate" 2 er-bif-lists-duplicate)
;; io module — side-effecting (writes to io buffer)
(er-register-bif! "io" "format" 1 er-bif-io-format)
(er-register-bif! "io" "format" 2 er-bif-io-format)
;; ets module — side-effecting (mutates table state)
(er-register-bif! "ets" "new" 2 er-bif-ets-new)
(er-register-bif! "ets" "insert" 2 er-bif-ets-insert)
(er-register-bif! "ets" "lookup" 2 er-bif-ets-lookup)
(er-register-bif! "ets" "delete" 1 er-bif-ets-delete)
(er-register-bif! "ets" "delete" 2 er-bif-ets-delete)
(er-register-bif! "ets" "tab2list" 1 er-bif-ets-tab2list)
(er-register-bif! "ets" "info" 2 er-bif-ets-info)
;; code module — side-effecting (mutates module registry, kills procs)
(er-register-bif! "code" "load_binary" 3 er-bif-code-load-binary)
(er-register-bif! "code" "purge" 1 er-bif-code-purge)
(er-register-bif! "code" "soft_purge" 1 er-bif-code-soft-purge)
(er-register-bif! "code" "which" 1 er-bif-code-which)
(er-register-bif! "code" "is_loaded" 1 er-bif-code-is-loaded)
(er-register-bif! "code" "all_loaded" 0 er-bif-code-all-loaded)
;; file module
(er-register-bif! "file" "read_file" 1 er-bif-file-read-file)
(er-register-bif! "file" "write_file" 2 er-bif-file-write-file)
(er-register-bif! "file" "delete" 1 er-bif-file-delete)
(er-mk-atom "ok")))
;; Register everything at load time.
(er-register-builtin-bifs!)

View File

@@ -1,16 +1,18 @@
{
"language": "erlang",
"total_pass": 530,
"total": 530,
"total_pass": 715,
"total": 715,
"suites": [
{"name":"tokenize","pass":62,"total":62,"status":"ok"},
{"name":"parse","pass":52,"total":52,"status":"ok"},
{"name":"eval","pass":346,"total":346,"status":"ok"},
{"name":"runtime","pass":39,"total":39,"status":"ok"},
{"name":"eval","pass":385,"total":385,"status":"ok"},
{"name":"runtime","pass":93,"total":93,"status":"ok"},
{"name":"ring","pass":4,"total":4,"status":"ok"},
{"name":"ping-pong","pass":4,"total":4,"status":"ok"},
{"name":"bank","pass":8,"total":8,"status":"ok"},
{"name":"echo","pass":7,"total":7,"status":"ok"},
{"name":"fib","pass":8,"total":8,"status":"ok"}
{"name":"fib","pass":8,"total":8,"status":"ok"},
{"name":"ffi","pass":14,"total":14,"status":"ok"},
{"name":"vm","pass":78,"total":78,"status":"ok"}
]
}

View File

@@ -1,18 +1,20 @@
# Erlang-on-SX Scoreboard
**Total: 530 / 530 tests passing**
**Total: 715 / 715 tests passing**
| | Suite | Pass | Total |
|---|---|---|---|
| ✅ | tokenize | 62 | 62 |
| ✅ | parse | 52 | 52 |
| ✅ | eval | 346 | 346 |
| ✅ | runtime | 39 | 39 |
| ✅ | eval | 385 | 385 |
| ✅ | runtime | 93 | 93 |
| ✅ | ring | 4 | 4 |
| ✅ | ping-pong | 4 | 4 |
| ✅ | bank | 8 | 8 |
| ✅ | echo | 7 | 7 |
| ✅ | fib | 8 | 8 |
| ✅ | ffi | 14 | 14 |
| ✅ | vm | 78 | 78 |
Generated by `lib/erlang/conformance.sh`.

View File

@@ -1125,6 +1125,222 @@
(er-eval-test "lists:duplicate val"
(nm (ev "hd(lists:duplicate(3, marker))")) "marker")
;; ── Phase 7: code:load_binary/3 ───────────────────────────────
(er-modules-reset!)
(er-eval-test "code:load_binary ok tag"
(nm (ev "element(1, code:load_binary(cl1, \"cl1.erl\", \"-module(cl1). foo() -> 1.\"))"))
"module")
(er-eval-test "code:load_binary ok name"
(nm (ev "element(2, code:load_binary(cl1, \"cl1.erl\", \"-module(cl1). foo() -> 1.\"))"))
"cl1")
(er-eval-test "code:load_binary then call"
(ev "cl1:foo()") 1)
(er-eval-test "code:load_binary reload v2"
(ev "code:load_binary(cl1, \"cl1.erl\", \"-module(cl1). foo() -> 99.\"), cl1:foo()")
99)
(er-eval-test "code:load_binary name mismatch tag"
(nm (ev "element(1, code:load_binary(cl2, \"x.erl\", \"-module(other). f() -> 0.\"))"))
"error")
(er-eval-test "code:load_binary name mismatch reason"
(nm (ev "element(2, code:load_binary(cl2, \"x.erl\", \"-module(other). f() -> 0.\"))"))
"module_name_mismatch")
(er-eval-test "code:load_binary badfile on garbage"
(nm (ev "element(2, code:load_binary(cl3, \"x.erl\", \"this is not erlang\"))"))
"badfile")
(er-eval-test "code:load_binary non-atom mod is badarg"
(nm (ev "element(2, code:load_binary(\"cl1\", \"x.erl\", \"-module(cl1). f() -> 0.\"))"))
"badarg")
;; ── Phase 7: code:purge/1 + code:soft_purge/1 ───────────────────
(er-modules-reset!)
;; purge unknown module → false
(er-eval-test "code:purge unknown"
(nm (ev "code:purge(nope)")) "false")
;; load, then purge without old version → false (nothing to purge)
(er-eval-test "code:purge no old"
(nm (ev "code:load_binary(pg1, \"pg1\", \"-module(pg1). v() -> 1.\"), code:purge(pg1)"))
"false")
;; load v1, load v2 (creates :old), purge with no live procs → true
(er-eval-test "code:purge after reload"
(nm (ev "code:load_binary(pg2, \"pg2\", \"-module(pg2). v() -> 1.\"), code:load_binary(pg2, \"pg2\", \"-module(pg2). v() -> 2.\"), code:purge(pg2)"))
"true")
;; idempotent: purging again returns false (already purged)
(er-eval-test "code:purge twice"
(nm (ev "code:load_binary(pg3, \"pg3\", \"-module(pg3). v() -> 1.\"), code:load_binary(pg3, \"pg3\", \"-module(pg3). v() -> 2.\"), code:purge(pg3), code:purge(pg3)"))
"false")
;; purge returns true whenever an :old slot exists, regardless of process tracking
;; (proper "kill lingering" semantics requires spawn/3 which is still stubbed)
(er-eval-test "code:purge with old slot present"
(nm (ev "code:load_binary(pg4, \"pg4\", \"-module(pg4). loop() -> receive stop -> ok end.\"),
Pid = spawn(fun () -> pg4:loop() end),
code:load_binary(pg4, \"pg4\", \"-module(pg4). loop() -> receive stop -> done end.\"),
code:purge(pg4)"))
"true")
;; soft_purge unknown → true (nothing to purge)
(er-eval-test "code:soft_purge unknown"
(nm (ev "code:soft_purge(nope)")) "true")
;; soft_purge with no old version → true
(er-eval-test "code:soft_purge no old"
(nm (ev "code:load_binary(sp1, \"sp1\", \"-module(sp1). v() -> 1.\"), code:soft_purge(sp1)"))
"true")
;; soft_purge with old + no lingering procs → true (clears :old)
(er-eval-test "code:soft_purge clean"
(nm (ev "code:load_binary(sp2, \"sp2\", \"-module(sp2). v() -> 1.\"), code:load_binary(sp2, \"sp2\", \"-module(sp2). v() -> 2.\"), code:soft_purge(sp2)"))
"true")
;; non-atom Mod is badarg (raise)
(er-eval-test "code:purge badarg"
(nm (ev "try code:purge(\"str\") catch error:badarg -> ok end")) "ok")
(er-eval-test "code:soft_purge badarg"
(nm (ev "try code:soft_purge(123) catch error:badarg -> ok end")) "ok")
;; ── Phase 7: code:which/1 + code:is_loaded/1 + code:all_loaded/0 ──
(er-modules-reset!)
(er-eval-test "code:which non_existing"
(nm (ev "code:which(nope)")) "non_existing")
(er-eval-test "code:which after load"
(nm (ev "code:load_binary(wh1, \"wh1\", \"-module(wh1). v() -> 1.\"), code:which(wh1)"))
"loaded")
(er-eval-test "code:is_loaded missing"
(nm (ev "code:is_loaded(nope)")) "false")
(er-eval-test "code:is_loaded tag"
(nm (ev "code:load_binary(il1, \"il1\", \"-module(il1). v() -> 1.\"), element(1, code:is_loaded(il1))"))
"file")
(er-eval-test "code:is_loaded value"
(nm (ev "code:load_binary(il2, \"il2\", \"-module(il2). v() -> 1.\"), element(2, code:is_loaded(il2))"))
"loaded")
(er-modules-reset!)
(er-eval-test "code:all_loaded empty"
(ev "length(code:all_loaded())") 0)
(er-modules-reset!)
(er-eval-test "code:all_loaded count"
(ev "code:load_binary(al1, \"al1\", \"-module(al1). v() -> 1.\"),
code:load_binary(al2, \"al2\", \"-module(al2). v() -> 1.\"),
length(code:all_loaded())")
2)
(er-eval-test "code:all_loaded first entry tag"
(nm (ev "code:load_binary(al3, \"al3\", \"-module(al3). v() -> 1.\"),
element(2, hd(code:all_loaded()))"))
"loaded")
(er-eval-test "code:which badarg"
(nm (ev "try code:which(\"str\") catch error:badarg -> ok end")) "ok")
(er-eval-test "code:is_loaded badarg"
(nm (ev "try code:is_loaded(123) catch error:badarg -> ok end")) "ok")
;; ── Phase 7: hot-reload call dispatch semantics ──────────────────
;; Cross-module M:F() calls always hit the CURRENT version;
;; local F() calls inside a module body resolve through the env
;; the function closed over (i.e. the version it was loaded with).
(er-modules-reset!)
;; M:F always hits current
(er-eval-test "cross-mod after reload v2"
(ev "code:load_binary(hr1, \"hr1\", \"-module(hr1). f() -> 1.\"),
code:load_binary(hr1, \"hr1\", \"-module(hr1). f() -> 2.\"),
hr1:f()")
2)
;; Local call inside reloaded module body resolves via fresh mod-env
;; (a() does a local b(); b() got upgraded too)
(er-eval-test "local call inside reloaded module body"
(ev "code:load_binary(hr2, \"hr2\", \"-module(hr2). a() -> b(). b() -> 1.\"),
code:load_binary(hr2, \"hr2\", \"-module(hr2). a() -> b(). b() -> 99.\"),
hr2:a()")
99)
;; Fun captured BEFORE reload, with local-call body, keeps v1 semantics
(er-eval-test "captured fun keeps closed-over env (local call)"
(ev "code:load_binary(hr3, \"hr3\", \"-module(hr3). get_fn() -> fun () -> b() end. b() -> 1.\"),
Fn = hr3:get_fn(),
code:load_binary(hr3, \"hr3\", \"-module(hr3). get_fn() -> fun () -> b() end. b() -> 99.\"),
Fn()")
1)
;; Fun captured BEFORE reload, with CROSS-mod body, sees v2's current
(er-eval-test "captured fun follows cross-mod to current"
(ev "code:load_binary(hr4, \"hr4\", \"-module(hr4). get_xref() -> fun () -> hr4:b() end. b() -> 1.\"),
Fn = hr4:get_xref(),
code:load_binary(hr4, \"hr4\", \"-module(hr4). get_xref() -> fun () -> hr4:b() end. b() -> 99.\"),
Fn()")
99)
;; Two captured funs from two different vintages
(er-eval-test "two funs from two vintages stay independent"
(ev "code:load_binary(hr5, \"hr5\", \"-module(hr5). gf() -> fun () -> v() end. v() -> 10.\"),
F1 = hr5:gf(),
code:load_binary(hr5, \"hr5\", \"-module(hr5). gf() -> fun () -> v() end. v() -> 20.\"),
F2 = hr5:gf(),
F1() + F2()")
30)
;; Version slot bumps correctly when a captured fun stays alive
(er-eval-test "version bumps despite captured funs"
(ev "code:load_binary(hr6, \"hr6\", \"-module(hr6). gf() -> fun () -> v() end. v() -> 1.\"),
_Pinned = hr6:gf(),
code:load_binary(hr6, \"hr6\", \"-module(hr6). gf() -> fun () -> v() end. v() -> 2.\"),
code:load_binary(hr6, \"hr6\", \"-module(hr6). gf() -> fun () -> v() end. v() -> 3.\"),
hr6:v()")
3)
;; ── Phase 7 capstone: full hot-reload ladder ───────────────────
;; Load v1 → spawn from inside module → load v2 → cross-mod hits v2 →
;; local call inside v1 process still resolves v1 → soft_purge refuses
;; while v1 procs alive → purge kills them.
;;
;; All stages must run in a single erlang-eval-ast call: each call resets
;; the scheduler (er-sched-init!) so cross-call Pid handles would point at
;; reaped processes.
(er-modules-reset!)
(define er-rt-cap-prog "code:load_binary(cap, \"cap.erl\", \"-module(cap). start() -> spawn(fun () -> loop() end). loop() -> receive {ping, From} -> From ! {pong, v1}, loop(); stop -> done end. tag() -> v1.\"), Tag1 = cap:tag(), Pid1 = cap:start(), code:load_binary(cap, \"cap.erl\", \"-module(cap). start() -> spawn(fun () -> loop() end). loop() -> receive {ping, From} -> From ! {pong, v2}, loop(); stop -> done end. tag() -> v2.\"), Tag2 = cap:tag(), _Pid2 = cap:start(), Soft1 = code:soft_purge(cap), Hard = code:purge(cap), Soft2 = code:soft_purge(cap), {Tag1, Tag2, Soft1, Hard, Soft2}")
(define er-rt-cap-result (ev er-rt-cap-prog))
(er-eval-test "capstone v1 tag direct"
(get (nth (get er-rt-cap-result :elements) 0) :name) "v1")
(er-eval-test "capstone v2 tag"
(get (nth (get er-rt-cap-result :elements) 1) :name) "v2")
(er-eval-test "capstone soft_purge while v1 alive = false"
(get (nth (get er-rt-cap-result :elements) 2) :name) "false")
(er-eval-test "capstone hard purge = true"
(get (nth (get er-rt-cap-result :elements) 3) :name) "true")
(er-eval-test "capstone soft_purge clean after hard = true"
(get (nth (get er-rt-cap-result :elements) 4) :name) "true")
(define
er-eval-test-summary
(str "eval " er-eval-test-pass "/" er-eval-test-count))

113
lib/erlang/tests/ffi.sx Normal file
View File

@@ -0,0 +1,113 @@
;; Phase 8 FFI BIF tests — one round-trip per BIF.
;; Each BIF lives in lib/erlang/runtime.sx (registered with
;; er-bif-registry) and wraps an SX-host primitive.
(define er-ffi-test-count 0)
(define er-ffi-test-pass 0)
(define er-ffi-test-fails (list))
(define
er-ffi-test
(fn
(name actual expected)
(set! er-ffi-test-count (+ er-ffi-test-count 1))
(if
(= actual expected)
(set! er-ffi-test-pass (+ er-ffi-test-pass 1))
(append! er-ffi-test-fails {:name name :expected expected :actual actual}))))
(define ffi-ev erlang-eval-ast)
(define ffi-nm (fn (v) (get v :name)))
;; ── file:read_file/1 + file:write_file/2 ────────────────────────
(er-ffi-test
"file:write_file ok"
(ffi-nm (ffi-ev "file:write_file(\"/tmp/er-ffi-1.txt\", \"hello\")"))
"ok")
(er-ffi-test
"file:read_file ok tag"
(ffi-nm (ffi-ev "element(1, file:read_file(\"/tmp/er-ffi-1.txt\"))"))
"ok")
(er-ffi-test
"file:read_file payload is binary"
(ffi-nm
(ffi-ev
"case file:read_file(\"/tmp/er-ffi-1.txt\") of {ok, B} -> is_binary(B) end"))
"true")
(er-ffi-test
"file:read_file content byte_size"
(ffi-ev
"case file:read_file(\"/tmp/er-ffi-1.txt\") of {ok, B} -> byte_size(B) end")
5)
(er-ffi-test
"file:read_file missing enoent"
(ffi-nm (ffi-ev "element(2, file:read_file(\"/tmp/er-ffi-no-such-xyz\"))"))
"enoent")
(er-ffi-test
"file:write_file bad path enoent"
(ffi-nm
(ffi-ev "element(2, file:write_file(\"/tmp/er-ffi-no-dir-xyz/x\", \"y\"))"))
"enoent")
(er-ffi-test
"file:write_file binary payload"
(ffi-ev
"file:write_file(\"/tmp/er-ffi-2.bin\", <<1, 2, 3, 4, 5>>), case file:read_file(\"/tmp/er-ffi-2.bin\") of {ok, B} -> byte_size(B) end")
5)
;; ── file:delete/1 ────────────────────────────────────────────────
(er-ffi-test
"file:delete ok"
(ffi-nm
(ffi-ev
"file:write_file(\"/tmp/er-ffi-del.txt\", \"x\"), file:delete(\"/tmp/er-ffi-del.txt\")"))
"ok")
(er-ffi-test
"file:read_file after delete enoent"
(ffi-nm
(ffi-ev
"file:write_file(\"/tmp/er-ffi-del2.txt\", \"x\"), file:delete(\"/tmp/er-ffi-del2.txt\"), element(2, file:read_file(\"/tmp/er-ffi-del2.txt\"))"))
"enoent")
;; ── Blocked BIFs (placeholder asserts so the suite documents intent) ──
;; crypto:hash/2, cid:from_bytes/1, cid:to_string/1, file:list_dir/1,
;; httpc:request/4, sqlite:* — documented in plans/erlang-on-sx.md
;; under Blockers. When the host runtime gains the underlying primitive,
;; the wrappers land in runtime.sx and tests appear here. For now we
;; assert each is NOT registered, so a future iteration that adds them
;; without updating this file fails fast.
(er-ffi-test
"crypto:hash unregistered"
(er-lookup-bif "crypto" "hash" 2)
nil)
(er-ffi-test
"cid:from_bytes unregistered"
(er-lookup-bif "cid" "from_bytes" 1)
nil)
(er-ffi-test
"file:list_dir unregistered"
(er-lookup-bif "file" "list_dir" 1)
nil)
(er-ffi-test
"httpc:request unregistered"
(er-lookup-bif "httpc" "request" 4)
nil)
(er-ffi-test
"sqlite:exec unregistered"
(er-lookup-bif "sqlite" "exec" 2)
nil)
(define
er-ffi-test-summary
(str "ffi " er-ffi-test-pass "/" er-ffi-test-count))

View File

@@ -134,6 +134,144 @@
(er-sched-current-pid)
nil)
;; ── Phase 7: module-version slots ───────────────────────────────
(er-modules-reset!)
(define er-rt-slot1 (er-mk-module-slot (er-env-new) nil 1))
(er-rt-test "slot tag" (get er-rt-slot1 :tag) "module")
(er-rt-test "slot version" (er-module-version er-rt-slot1) 1)
(er-rt-test "slot old nil" (er-module-old-env er-rt-slot1) nil)
(er-rt-test "slot current not nil" (= (er-module-current-env er-rt-slot1) nil) false)
(erlang-load-module "-module(hr1). a() -> 1.")
(define er-rt-reg (er-modules-get))
(er-rt-test "registry has hr1" (dict-has? er-rt-reg "hr1") true)
(er-rt-test "v1 on first load" (er-module-version (get er-rt-reg "hr1")) 1)
(er-rt-test "v1 old is nil" (er-module-old-env (get er-rt-reg "hr1")) nil)
(er-rt-test "v1 current not nil" (= (er-module-current-env (get er-rt-reg "hr1")) nil) false)
(define er-rt-env-v1 (er-module-current-env (get er-rt-reg "hr1")))
(erlang-load-module "-module(hr1). a() -> 2.")
(er-rt-test "v2 on second load" (er-module-version (get er-rt-reg "hr1")) 2)
(er-rt-test "v2 old is v1 env" (er-module-old-env (get er-rt-reg "hr1")) er-rt-env-v1)
(er-rt-test "v2 current is new" (= (er-module-current-env (get er-rt-reg "hr1")) er-rt-env-v1) false)
(erlang-load-module "-module(hr1). a() -> 3.")
(er-rt-test "v3 on third load" (er-module-version (get er-rt-reg "hr1")) 3)
(er-modules-reset!)
(er-rt-test "registry-reset clears" (dict-has? (er-modules-get) "hr1") false)
;; ── Phase 8: FFI BIF registry ──────────────────────────────────
(er-bif-registry-reset!)
(er-rt-test "empty registry" (len (er-list-bifs)) 0)
(er-rt-test "lookup miss" (er-lookup-bif "crypto" "hash" 2) nil)
(er-register-bif! "fake" "echo" 1 (fn (vs) (nth vs 0)))
(er-rt-test "register grows registry" (len (er-list-bifs)) 1)
(define er-rt-bif-hit (er-lookup-bif "fake" "echo" 1))
(er-rt-test "lookup hit module" (get er-rt-bif-hit :module) "fake")
(er-rt-test "lookup hit name" (get er-rt-bif-hit :name) "echo")
(er-rt-test "lookup hit arity" (get er-rt-bif-hit :arity) 1)
(er-rt-test "lookup hit pure?" (get er-rt-bif-hit :pure?) false)
(er-rt-test "fn invocable" ((get er-rt-bif-hit :fn) (list 42)) 42)
;; Re-register replaces (same key)
(er-register-bif! "fake" "echo" 1 (fn (vs) "replaced"))
(er-rt-test "re-register same key, count unchanged" (len (er-list-bifs)) 1)
(er-rt-test "re-register replaces fn"
((get (er-lookup-bif "fake" "echo" 1) :fn) (list 99)) "replaced")
;; Pure variant
(er-register-pure-bif! "fake" "pure" 2 (fn (vs) (+ (nth vs 0) (nth vs 1))))
(er-rt-test "pure registered separately, count 2" (len (er-list-bifs)) 2)
(er-rt-test "pure flag true"
(get (er-lookup-bif "fake" "pure" 2) :pure?) true)
(er-rt-test "pure fn invocable"
((get (er-lookup-bif "fake" "pure" 2) :fn) (list 7 8)) 15)
;; Arity disambiguation: same module+name, different arity = distinct entries
(er-register-bif! "fake" "echo" 2 (fn (vs) (list (nth vs 0) (nth vs 1))))
(er-rt-test "arity disambiguation count" (len (er-list-bifs)) 3)
(er-rt-test "arity-1 lookup still works"
((get (er-lookup-bif "fake" "echo" 1) :fn) (list 11)) "replaced")
(er-rt-test "arity-2 lookup independent"
(len ((get (er-lookup-bif "fake" "echo" 2) :fn) (list 1 2))) 2)
;; Reset clears the registry
(er-bif-registry-reset!)
(er-rt-test "reset clears" (len (er-list-bifs)) 0)
(er-rt-test "reset lookup nil" (er-lookup-bif "fake" "echo" 1) nil)
;; ── Phase 8: term marshalling (er-to-sx / er-of-sx) ─────────────
;; er-to-sx: Erlang → SX
(er-rt-test "to-sx atom" (er-to-sx (er-mk-atom "foo")) (make-symbol "foo"))
(er-rt-test "to-sx atom is symbol" (type-of (er-to-sx (er-mk-atom "x"))) "symbol")
(er-rt-test "to-sx nil" (er-to-sx (er-mk-nil)) (list))
(er-rt-test "to-sx integer passthrough" (er-to-sx 42) 42)
(er-rt-test "to-sx float passthrough" (er-to-sx 3.14) 3.14)
(er-rt-test "to-sx boolean passthrough" (er-to-sx true) true)
(er-rt-test "to-sx binary → string"
(er-to-sx (er-mk-binary (list 104 105 33))) "hi!")
(er-rt-test "to-sx cons → list"
(er-to-sx (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))) (list 1 2 3))
(er-rt-test "to-sx tuple → list"
(er-to-sx (er-mk-tuple (list 1 2 3))) (list 1 2 3))
(er-rt-test "to-sx nested cons"
(er-to-sx (er-mk-cons (er-mk-atom "a") (er-mk-cons 7 (er-mk-nil))))
(list (make-symbol "a") 7))
;; er-of-sx: SX → Erlang
(er-rt-test "of-sx symbol"
(get (er-of-sx (make-symbol "ok")) :name) "ok")
(er-rt-test "of-sx symbol is atom"
(er-atom? (er-of-sx (make-symbol "x"))) true)
(er-rt-test "of-sx string is binary"
(er-binary? (er-of-sx "hi")) true)
(er-rt-test "of-sx string bytes"
(get (er-of-sx "hi") :bytes) (list 104 105))
(er-rt-test "of-sx integer passthrough"
(er-of-sx 42) 42)
(er-rt-test "of-sx empty list → nil"
(er-nil? (er-of-sx (list))) true)
(er-rt-test "of-sx list → cons chain length"
(er-list-length (er-of-sx (list 1 2 3 4))) 4)
(er-rt-test "of-sx list head/tail"
(get (er-of-sx (list 10 20)) :head) 10)
;; Round-trips
(er-rt-test "rtrip integer" (er-to-sx (er-of-sx 99)) 99)
(er-rt-test "rtrip atom"
(get (er-of-sx (er-to-sx (er-mk-atom "abc"))) :name) "abc")
(er-rt-test "rtrip binary bytes"
(get (er-of-sx (er-to-sx (er-mk-binary (list 1 2 3)))) :bytes) (list 1 2 3))
(er-rt-test "rtrip cons-of-ints length"
(er-list-length (er-of-sx (er-to-sx
(er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))))) 3)
;; Tuples don't round-trip exactly (er-to-sx flattens tuples to lists);
;; documented one-way conversion.
(er-rt-test "to-sx of tuple loses tag"
(er-cons? (er-of-sx (er-to-sx (er-mk-tuple (list 1 2 3))))) true)
;; Re-populate built-in BIFs so subsequent test files (ring, ping-pong, etc.)
;; can call length/spawn/etc. The migration onto the registry means a reset
;; here would otherwise break the rest of the conformance suite.
(er-register-builtin-bifs!)
(define
er-rt-test-summary
(str "runtime " er-rt-test-pass "/" er-rt-test-count))

403
lib/erlang/tests/vm.sx Normal file
View File

@@ -0,0 +1,403 @@
;; Phase 9 — stub VM opcode dispatcher tests.
;; Verifies the dispatcher shape (mirrors plans/sx-vm-opcode-extension.md
;; for when 9a integrates) and the three pattern-match opcodes (9b)
;; route to the correct er-match-* impl.
(define er-vm-test-count 0)
(define er-vm-test-pass 0)
(define er-vm-test-fails (list))
(define
er-vm-test
(fn
(name actual expected)
(set! er-vm-test-count (+ er-vm-test-count 1))
(if
(= actual expected)
(set! er-vm-test-pass (+ er-vm-test-pass 1))
(append! er-vm-test-fails {:name name :expected expected :actual actual}))))
;; ── dispatcher core ─────────────────────────────────────────────
(er-vm-test
"tuple opcode registered"
(= (er-vm-lookup-opcode-by-id 128) nil)
false)
(er-vm-test
"tuple opcode name"
(get (er-vm-lookup-opcode-by-id 128) :name)
"OP_PATTERN_TUPLE")
(er-vm-test
"list opcode by name"
(get (er-vm-lookup-opcode-by-name "OP_PATTERN_LIST") :id)
129)
(er-vm-test
"binary opcode by name"
(get (er-vm-lookup-opcode-by-name "OP_PATTERN_BINARY") :id)
130)
(er-vm-test "lookup miss by id" (er-vm-lookup-opcode-by-id 999) nil)
(er-vm-test "lookup miss by name" (er-vm-lookup-opcode-by-name "OP_NOPE") nil)
(er-vm-test
"opcode list has 3+"
(>= (len (er-vm-list-opcodes)) 3)
true)
;; ── OP_PATTERN_TUPLE ────────────────────────────────────────────
;; Pattern: {ok, X} matches value {ok, 42} → X bound to 42
(define er-vm-t1-env (er-env-new))
(define er-vm-t1-pat {:type "tuple" :elements (list {:type "atom" :value "ok"} {:name "X" :type "var"})})
(define er-vm-t1-val (er-mk-tuple (list (er-mk-atom "ok") 42)))
(er-vm-test
"OP_PATTERN_TUPLE match"
(er-vm-dispatch 128 (list er-vm-t1-pat er-vm-t1-val er-vm-t1-env))
true)
(er-vm-test "OP_PATTERN_TUPLE binds var" (get er-vm-t1-env "X") 42)
;; Same pattern against {error, ...} → false
(define er-vm-t2-env (er-env-new))
(define er-vm-t2-val (er-mk-tuple (list (er-mk-atom "error") 7)))
(er-vm-test
"OP_PATTERN_TUPLE no-match"
(er-vm-dispatch 128 (list er-vm-t1-pat er-vm-t2-val er-vm-t2-env))
false)
;; Wrong arity tuple — pattern has 2 elements, value has 3
(define er-vm-t3-env (er-env-new))
(define
er-vm-t3-val
(er-mk-tuple (list (er-mk-atom "ok") 1 2)))
(er-vm-test
"OP_PATTERN_TUPLE arity mismatch"
(er-vm-dispatch 128 (list er-vm-t1-pat er-vm-t3-val er-vm-t3-env))
false)
;; ── OP_PATTERN_LIST (cons) ──────────────────────────────────────
;; Pattern: [H | T] matches [1, 2, 3] → H=1, T=[2,3]
(define er-vm-l1-env (er-env-new))
(define er-vm-l1-pat {:type "cons" :tail {:name "T" :type "var"} :head {:name "H" :type "var"}})
(define
er-vm-l1-val
(er-mk-cons
1
(er-mk-cons 2 (er-mk-cons 3 (er-mk-nil)))))
(er-vm-test
"OP_PATTERN_LIST match"
(er-vm-dispatch 129 (list er-vm-l1-pat er-vm-l1-val er-vm-l1-env))
true)
(er-vm-test "OP_PATTERN_LIST binds head" (get er-vm-l1-env "H") 1)
(er-vm-test
"OP_PATTERN_LIST tail is cons"
(er-cons? (get er-vm-l1-env "T"))
true)
;; [H|T] against empty list → false
(define er-vm-l2-env (er-env-new))
(er-vm-test
"OP_PATTERN_LIST no-match on nil"
(er-vm-dispatch 129 (list er-vm-l1-pat (er-mk-nil) er-vm-l2-env))
false)
;; ── OP_PATTERN_BINARY ───────────────────────────────────────────
;; Pattern <<A:8>> against <<42>> → A bound to 42
(define er-vm-b1-env (er-env-new))
(define er-vm-b1-pat {:type "binary" :segments (list {:value {:name "A" :type "var"} :size {:type "integer" :value "8"} :spec "integer"})})
(define er-vm-b1-val (er-mk-binary (list 42)))
(er-vm-test
"OP_PATTERN_BINARY match"
(er-vm-dispatch 130 (list er-vm-b1-pat er-vm-b1-val er-vm-b1-env))
true)
(er-vm-test
"OP_PATTERN_BINARY binds segment"
(get er-vm-b1-env "A")
42)
;; Same pattern against wrong-size binary (2 bytes) → false
(define er-vm-b2-env (er-env-new))
(define er-vm-b2-val (er-mk-binary (list 42 99)))
(er-vm-test
"OP_PATTERN_BINARY size mismatch"
(er-vm-dispatch 130 (list er-vm-b1-pat er-vm-b2-val er-vm-b2-env))
false)
;; ── dispatch error path ────────────────────────────────────────
(define er-vm-err-caught (list nil))
(guard
(c (:else (set-nth! er-vm-err-caught 0 (str c))))
(er-vm-dispatch 999 (list)))
(er-vm-test
"unknown opcode raises"
(string-contains? (str (nth er-vm-err-caught 0)) "unknown opcode")
true)
;; ── Phase 9c — OP_PERFORM / OP_HANDLE ───────────────────────────
(er-vm-test "perform opcode by id"
(get (er-vm-lookup-opcode-by-id 131) :name) "OP_PERFORM")
(er-vm-test "handle opcode by id"
(get (er-vm-lookup-opcode-by-id 132) :name) "OP_HANDLE")
(define er-vm-pf-caught (list nil))
(guard (c (:else (set-nth! er-vm-pf-caught 0 c)))
(er-vm-dispatch 131 (list "yield" (list 42))))
(er-vm-test "perform raises tagged"
(get (nth er-vm-pf-caught 0) :tag) "vm-effect")
(er-vm-test "perform effect name"
(get (nth er-vm-pf-caught 0) :effect) "yield")
(er-vm-test "perform args carried"
(nth (get (nth er-vm-pf-caught 0) :args) 0) 42)
(er-vm-test "handle catches matching effect"
(er-vm-dispatch 132
(list
(fn () (er-vm-dispatch 131 (list "yield" (list 7))))
"yield"
(fn (args) (+ (nth args 0) 100))))
107)
(er-vm-test "handle no-effect returns thunk result"
(er-vm-dispatch 132
(list
(fn () 99)
"yield"
(fn (args) "handler ran")))
99)
(define er-vm-rt-caught (list nil))
(guard (c (:else (set-nth! er-vm-rt-caught 0 c)))
(er-vm-dispatch 132
(list
(fn () (er-vm-dispatch 131 (list "other" (list))))
"yield"
(fn (args) "wrong"))))
(er-vm-test "handle rethrows non-matching"
(get (nth er-vm-rt-caught 0) :effect) "other")
(er-vm-test "nested handles separate effect names"
(er-vm-dispatch 132
(list
(fn ()
(er-vm-dispatch 132
(list
(fn () (er-vm-dispatch 131 (list "b" (list 5))))
"a"
(fn (args) "inner-handled"))))
"b"
(fn (args) (+ (nth args 0) 1000))))
1005)
;; ── Phase 9d — OP_RECEIVE_SCAN ──────────────────────────────────
(er-vm-test "receive-scan opcode by id"
(get (er-vm-lookup-opcode-by-id 133) :name) "OP_RECEIVE_SCAN")
;; Pattern: receive {ok, X} -> X end against mailbox [{error, 1}, {ok, 42}, foo]
(define er-vm-r1-env (er-env-new))
(define er-vm-r1-clauses
(list
{:pattern {:type "tuple"
:elements (list
{:type "atom" :value "ok"}
{:type "var" :name "X"})}
:guards (list)
:body (list {:type "var" :name "X"})}))
(define er-vm-r1-mbox
(list
(er-mk-tuple (list (er-mk-atom "error") 1))
(er-mk-tuple (list (er-mk-atom "ok") 42))
(er-mk-atom "foo")))
(define er-vm-r1-result
(er-vm-dispatch 133 (list er-vm-r1-clauses er-vm-r1-mbox er-vm-r1-env)))
(er-vm-test "scan finds match"
(get er-vm-r1-result :matched) true)
(er-vm-test "scan reports correct index"
(get er-vm-r1-result :index) 1)
(er-vm-test "scan binds var"
(get er-vm-r1-env "X") 42)
(er-vm-test "scan leaves body unevaluated"
(= (get er-vm-r1-result :body) nil) false)
;; No match case
(define er-vm-r2-env (er-env-new))
(define er-vm-r2-mbox (list (er-mk-atom "nope") 99))
(define er-vm-r2-result
(er-vm-dispatch 133 (list er-vm-r1-clauses er-vm-r2-mbox er-vm-r2-env)))
(er-vm-test "scan no-match"
(get er-vm-r2-result :matched) false)
(er-vm-test "scan no-match leaves env clean"
(dict-has? er-vm-r2-env "X") false)
;; Empty mailbox
(define er-vm-r3-result
(er-vm-dispatch 133 (list er-vm-r1-clauses (list) (er-env-new))))
(er-vm-test "scan empty mailbox"
(get er-vm-r3-result :matched) false)
;; First-match wins (arrival order)
(define er-vm-r4-env (er-env-new))
(define er-vm-r4-mbox
(list
(er-mk-tuple (list (er-mk-atom "ok") 1))
(er-mk-tuple (list (er-mk-atom "ok") 2))))
(define er-vm-r4-result
(er-vm-dispatch 133 (list er-vm-r1-clauses er-vm-r4-mbox er-vm-r4-env)))
(er-vm-test "scan first-match wins (index 0)"
(get er-vm-r4-result :index) 0)
(er-vm-test "scan binds first match's var"
(get er-vm-r4-env "X") 1)
;; ── Phase 9e — OP_SPAWN / OP_SEND ───────────────────────────────
(er-vm-procs-reset!)
(er-vm-test "spawn opcode by id"
(get (er-vm-lookup-opcode-by-id 134) :name) "OP_SPAWN")
(er-vm-test "send opcode by id"
(get (er-vm-lookup-opcode-by-id 135) :name) "OP_SEND")
(define er-vm-fn (fn () "body"))
(define er-vm-p1 (er-vm-dispatch 134 (list er-vm-fn (list))))
(define er-vm-p2 (er-vm-dispatch 134 (list er-vm-fn (list "arg"))))
(er-vm-test "spawn returns pid 0 first"
er-vm-p1 0)
(er-vm-test "spawn returns pid 1 second"
er-vm-p2 1)
(er-vm-test "proc count is 2"
(er-vm-proc-count) 2)
(er-vm-test "spawned proc state runnable"
(er-vm-proc-state er-vm-p1) "runnable")
(er-vm-test "spawned proc mailbox empty"
(len (er-vm-proc-mailbox er-vm-p1)) 0)
(er-vm-test "spawned proc has 8 registers"
(len (get (er-vm-proc-get er-vm-p1) :registers)) 8)
;; OP_SEND appends to target's mailbox, preserves arrival order.
(er-vm-test "send returns true on valid pid"
(er-vm-dispatch 135 (list er-vm-p1 "msg1")) true)
(er-vm-dispatch 135 (list er-vm-p1 "msg2")
)
(er-vm-dispatch 135 (list er-vm-p1 "msg3"))
(er-vm-test "mailbox length after 3 sends"
(len (er-vm-proc-mailbox er-vm-p1)) 3)
(er-vm-test "mailbox preserves order — first"
(nth (er-vm-proc-mailbox er-vm-p1) 0) "msg1")
(er-vm-test "mailbox preserves order — last"
(nth (er-vm-proc-mailbox er-vm-p1) 2) "msg3")
;; send to nonexistent pid returns false (doesn't crash)
(er-vm-test "send to unknown pid is false"
(er-vm-dispatch 135 (list 99999 "x")) false)
;; Isolation: msgs to p1 don't appear in p2's mailbox
(er-vm-test "isolation — p2 mailbox empty"
(len (er-vm-proc-mailbox er-vm-p2)) 0)
;; reset clears
(er-vm-procs-reset!)
(er-vm-test "reset clears procs"
(er-vm-proc-count) 0)
(er-vm-test "reset resets pid counter"
(er-vm-dispatch 134 (list er-vm-fn (list))) 0)
;; ── Phase 9f — hot-BIF dispatch table ───────────────────────────
;; Each opcode skips the registry lookup and calls the underlying
;; er-bif-* directly. Verify each returns the same result as going
;; through er-apply-bif.
(er-vm-test "BIF_LENGTH opcode by id"
(get (er-vm-lookup-opcode-by-id 136) :name) "OP_BIF_LENGTH")
(er-vm-test "BIF_LENGTH on 3-cons"
(er-vm-dispatch 136
(list (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))))
3)
(er-vm-test "BIF_HD on cons"
(er-vm-dispatch 137 (list (er-mk-cons 99 (er-mk-nil)))) 99)
(er-vm-test "BIF_TL is cons"
(er-cons? (er-vm-dispatch 138
(list (er-mk-cons 1 (er-mk-cons 2 (er-mk-nil)))))) true)
(er-vm-test "BIF_ELEMENT pulls index"
(er-vm-dispatch 139 (list 2 (er-mk-tuple (list "a" "b" "c")))) "b")
(er-vm-test "BIF_TUPLE_SIZE on 4-tuple"
(er-vm-dispatch 140 (list (er-mk-tuple (list 1 2 3 4)))) 4)
(er-vm-test "BIF_LISTS_REVERSE preserves elements"
(er-list-length (er-vm-dispatch 141
(list (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))))) 3)
(er-vm-test "BIF_LISTS_REVERSE actually reverses"
(get (er-vm-dispatch 141
(list (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil)))))) :head) 3)
(er-vm-test "BIF_IS_INTEGER true on int"
(get (er-vm-dispatch 142 (list 42)) :name) "true")
(er-vm-test "BIF_IS_INTEGER false on float"
(get (er-vm-dispatch 142 (list 3.14)) :name) "false")
(er-vm-test "BIF_IS_ATOM true"
(get (er-vm-dispatch 143 (list (er-mk-atom "ok"))) :name) "true")
(er-vm-test "BIF_IS_ATOM false on int"
(get (er-vm-dispatch 143 (list 7)) :name) "false")
(er-vm-test "BIF_IS_LIST true on cons"
(get (er-vm-dispatch 144
(list (er-mk-cons 1 (er-mk-nil)))) :name) "true")
(er-vm-test "BIF_IS_LIST true on nil"
(get (er-vm-dispatch 144 (list (er-mk-nil))) :name) "true")
(er-vm-test "BIF_IS_LIST false on tuple"
(get (er-vm-dispatch 144 (list (er-mk-tuple (list)))) :name) "false")
(er-vm-test "BIF_IS_TUPLE true"
(get (er-vm-dispatch 145 (list (er-mk-tuple (list 1)))) :name) "true")
(er-vm-test "BIF_IS_TUPLE false on int"
(get (er-vm-dispatch 145 (list 5)) :name) "false")
;; Sanity: total opcode count grew (3 patterns + perform + handle +
;; receive-scan + spawn + send + 10 hot-BIFs = 16+ registered).
(er-vm-test "opcode list has 16+"
(>= (len (er-vm-list-opcodes)) 16) true)
;; ── Phase 9i — host opcode-id resolution ────────────────────────
;; Requires a binary with the erlang_ext extension registered (9h).
;; The loop runs conformance against exactly that binary.
(er-vm-test "host id: OP_PATTERN_TUPLE = 222"
(er-vm-host-opcode-id "erlang.OP_PATTERN_TUPLE") 222)
(er-vm-test "host id: OP_BIF_IS_TUPLE = 239"
(er-vm-host-opcode-id "erlang.OP_BIF_IS_TUPLE") 239)
(er-vm-test "host id: unknown name -> nil"
(er-vm-host-opcode-id "erlang.OP_NOPE") nil)
(er-vm-test "effective id prefers host when present"
(er-vm-effective-opcode-id "erlang.OP_BIF_LENGTH" 136) 230)
(er-vm-test "effective id falls back to stub on nil"
(er-vm-effective-opcode-id "erlang.OP_NOPE" 999) 999)
;; The full erlang.OP_* namespace resolves to the contiguous 222-239 block.
(er-vm-test "host ids contiguous 222..239"
(let ((names (list "erlang.OP_PATTERN_TUPLE" "erlang.OP_PATTERN_LIST"
"erlang.OP_PATTERN_BINARY" "erlang.OP_PERFORM"
"erlang.OP_HANDLE" "erlang.OP_RECEIVE_SCAN"
"erlang.OP_SPAWN" "erlang.OP_SEND"
"erlang.OP_BIF_LENGTH" "erlang.OP_BIF_HD"
"erlang.OP_BIF_TL" "erlang.OP_BIF_ELEMENT"
"erlang.OP_BIF_TUPLE_SIZE" "erlang.OP_BIF_LISTS_REVERSE"
"erlang.OP_BIF_IS_INTEGER" "erlang.OP_BIF_IS_ATOM"
"erlang.OP_BIF_IS_LIST" "erlang.OP_BIF_IS_TUPLE"))
(ok (list true)))
(for-each
(fn (i)
(when (not (= (er-vm-host-opcode-id (nth names i)) (+ 222 i)))
(set-nth! ok 0 false)))
(range 0 (len names)))
(nth ok 0))
true)
(define er-vm-test-summary (str "vm " er-vm-test-pass "/" er-vm-test-count))

View File

@@ -669,96 +669,23 @@
(define
er-apply-bif
(fn
(name vs)
(cond
(= name "is_integer") (er-bif-is-integer vs)
(= name "is_atom") (er-bif-is-atom vs)
(= name "is_list") (er-bif-is-list vs)
(= name "is_tuple") (er-bif-is-tuple vs)
(= name "is_number") (er-bif-is-number vs)
(= name "is_float") (er-bif-is-float vs)
(= name "is_boolean") (er-bif-is-boolean vs)
(= name "length") (er-bif-length vs)
(= name "hd") (er-bif-hd vs)
(= name "tl") (er-bif-tl vs)
(= name "element") (er-bif-element vs)
(= name "tuple_size") (er-bif-tuple-size vs)
(= name "atom_to_list") (er-bif-atom-to-list vs)
(= name "list_to_atom") (er-bif-list-to-atom vs)
(= name "is_pid") (er-bif-is-pid vs)
(= name "is_reference") (er-bif-is-reference vs)
(= name "is_binary") (er-bif-is-binary vs)
(= name "byte_size") (er-bif-byte-size vs)
(= name "abs") (er-bif-abs vs)
(= name "min") (er-bif-min vs)
(= name "max") (er-bif-max vs)
(= name "tuple_to_list") (er-bif-tuple-to-list vs)
(= name "list_to_tuple") (er-bif-list-to-tuple vs)
(= name "integer_to_list") (er-bif-integer-to-list vs)
(= name "list_to_integer") (er-bif-list-to-integer vs)
(= name "is_function") (er-bif-is-function vs)
(= name "self") (er-bif-self vs)
(= name "spawn") (er-bif-spawn vs)
(= name "exit") (er-bif-exit vs)
(= name "make_ref") (er-bif-make-ref vs)
(= name "link") (er-bif-link vs)
(= name "unlink") (er-bif-unlink vs)
(= name "monitor") (er-bif-monitor vs)
(= name "demonitor") (er-bif-demonitor vs)
(= name "process_flag") (er-bif-process-flag vs)
(= name "register") (er-bif-register vs)
(= name "unregister") (er-bif-unregister vs)
(= name "whereis") (er-bif-whereis vs)
(= name "registered") (er-bif-registered vs)
(= name "throw") (raise (er-mk-throw-marker (er-bif-arg1 vs "throw")))
(= name "error") (raise (er-mk-error-marker (er-bif-arg1 vs "error")))
:else (error
(str "Erlang: undefined function '" name "/" (len vs) "'")))))
(fn (name vs)
(let ((entry (er-lookup-bif "erlang" name (len vs))))
(if (not (= entry nil))
((get entry :fn) vs)
(error (str "Erlang: undefined function '" name "/" (len vs) "'"))))))
(define
er-apply-remote-bif
(fn
(mod name vs)
(fn (mod name vs)
(cond
(dict-has? (er-modules-get) mod)
(er-apply-user-module mod name vs)
(= mod "lists") (er-apply-lists-bif name vs)
(= mod "io") (er-apply-io-bif name vs)
(= mod "erlang") (er-apply-bif name vs)
(= mod "ets") (er-apply-ets-bif name vs)
:else (error
(str "Erlang: undefined module '" mod "'")))))
(define
er-apply-lists-bif
(fn
(name vs)
(cond
(= name "reverse") (er-bif-lists-reverse vs)
(= name "map") (er-bif-lists-map vs)
(= name "foldl") (er-bif-lists-foldl vs)
(= name "seq") (er-bif-lists-seq vs)
(= name "sum") (er-bif-lists-sum vs)
(= name "nth") (er-bif-lists-nth vs)
(= name "last") (er-bif-lists-last vs)
(= name "member") (er-bif-lists-member vs)
(= name "append") (er-bif-lists-append vs)
(= name "filter") (er-bif-lists-filter vs)
(= name "any") (er-bif-lists-any vs)
(= name "all") (er-bif-lists-all vs)
(= name "duplicate") (er-bif-lists-duplicate vs)
:else (error
(str "Erlang: undefined 'lists:" name "/" (len vs) "'")))))
(define
er-apply-io-bif
(fn
(name vs)
(cond
(= name "format") (er-bif-io-format vs)
:else (error
(str "Erlang: undefined 'io:" name "/" (len vs) "'")))))
(er-apply-user-module mod name vs)
:else
(let ((entry (er-lookup-bif mod name (len vs))))
(if (not (= entry nil))
((get entry :fn) vs)
(error (str "Erlang: undefined remote function '" mod ":" name "/" (len vs) "'")))))))
(define
er-bif-arg1
@@ -1911,3 +1838,180 @@
(fn (_) (set! out (er-mk-cons v out)))
(range 0 n))
out))))
;; ── code module (Phase 7 hot-reload) ─────────────────────────────
(define er-source-walk-bytes!
(fn (n bytes-box)
(cond
(er-nil? n) true
(er-cons? n)
(let ((h (get n :head)))
(cond
(= (type-of h) "number")
(do (append! (nth bytes-box 0) h)
(er-source-walk-bytes! (get n :tail) bytes-box))
:else (do (set-nth! bytes-box 0 nil) false)))
:else (do (set-nth! bytes-box 0 nil) false))))
(define er-source-to-string
(fn (v)
(cond
(= (type-of v) "string") v
(er-binary? v) (list->string (map integer->char (get v :bytes)))
(or (er-nil? v) (er-cons? v))
(let ((box (list (list))))
(er-source-walk-bytes! v box)
(cond
(= (nth box 0) nil) nil
:else (list->string (map integer->char (nth box 0)))))
:else nil)))
(define er-bif-code-load-binary
(fn (vs)
(let ((mod-arg (nth vs 0)) (src-arg (nth vs 2)))
(cond
(not (er-atom? mod-arg))
(er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg")))
:else
(let ((src-str (er-source-to-string src-arg)))
(cond
(= src-str nil)
(er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg")))
:else
(let ((result-box (list nil)) (failed-box (list false)))
(guard
(c (:else (set-nth! failed-box 0 true)))
(set-nth! result-box 0 (erlang-load-module src-str)))
(cond
(nth failed-box 0)
(er-mk-tuple
(list (er-mk-atom "error") (er-mk-atom "badfile")))
(not (= (get (nth result-box 0) :name) (get mod-arg :name)))
(er-mk-tuple
(list (er-mk-atom "error") (er-mk-atom "module_name_mismatch")))
:else
(er-mk-tuple (list (er-mk-atom "module") mod-arg))))))))))
(define er-env-derived-from?
(fn (env target-env)
;; Object-identity check, NOT value `=`. On evaluators where dict `=`
;; is structural/deep, comparing closure envs (which are large and
;; cyclic — a module fun's env references the fun) does not terminate.
;; `identical?` is pointer identity on every host and is the actual
;; intended semantics: "is this the same env object".
(cond
(identical? env target-env) true
:else
(let ((ks (keys env)) (found-ref (list false)))
(for-each
(fn (i)
(when (not (nth found-ref 0))
(let ((v (get env (nth ks i))))
(when (and (er-fun? v) (identical? (get v :env) target-env))
(set-nth! found-ref 0 true)))))
(range 0 (len ks)))
(nth found-ref 0)))))
(define er-procs-on-env
(fn (target-env)
(let ((all-keys (keys (er-sched-processes)))
(matches (list)))
(for-each
(fn (i)
(let ((proc (get (er-sched-processes) (nth all-keys i))))
(let ((init-fun (get proc :initial-fun)))
(when (and (not (= init-fun nil))
(er-fun? init-fun)
(er-env-derived-from? (get init-fun :env) target-env)
(not (= (get proc :state) "dead")))
(append! matches (get proc :pid))))))
(range 0 (len all-keys)))
matches)))
(define er-bif-code-purge
(fn (vs)
(let ((mod-arg (nth vs 0)))
(cond
(not (er-atom? mod-arg))
(raise (er-mk-error-marker (er-mk-atom "badarg")))
:else
(let ((registry (er-modules-get)) (mod-name (get mod-arg :name)))
(cond
(not (dict-has? registry mod-name)) (er-mk-atom "false")
:else
(let ((slot (get registry mod-name)))
(cond
(= (er-module-old-env slot) nil) (er-mk-atom "false")
:else
(let ((procs (er-procs-on-env (er-module-old-env slot))))
(for-each
(fn (i) (er-cascade-exit! (nth procs i) (er-mk-atom "killed")))
(range 0 (len procs)))
(dict-set! registry mod-name
(er-mk-module-slot (er-module-current-env slot) nil
(er-module-version slot)))
(er-mk-atom "true"))))))))))
(define er-bif-code-soft-purge
(fn (vs)
(let ((mod-arg (nth vs 0)))
(cond
(not (er-atom? mod-arg))
(raise (er-mk-error-marker (er-mk-atom "badarg")))
:else
(let ((registry (er-modules-get)) (mod-name (get mod-arg :name)))
(cond
(not (dict-has? registry mod-name)) (er-mk-atom "true")
:else
(let ((slot (get registry mod-name)))
(cond
(= (er-module-old-env slot) nil) (er-mk-atom "true")
:else
(let ((procs (er-procs-on-env (er-module-old-env slot))))
(cond
(> (len procs) 0) (er-mk-atom "false")
:else
(do
(dict-set! registry mod-name
(er-mk-module-slot (er-module-current-env slot) nil
(er-module-version slot)))
(er-mk-atom "true"))))))))))))
(define er-bif-code-which
(fn (vs)
(let ((mod-arg (nth vs 0)))
(cond
(not (er-atom? mod-arg))
(raise (er-mk-error-marker (er-mk-atom "badarg")))
(dict-has? (er-modules-get) (get mod-arg :name))
(er-mk-atom "loaded")
:else (er-mk-atom "non_existing")))))
(define er-bif-code-is-loaded
(fn (vs)
(let ((mod-arg (nth vs 0)))
(cond
(not (er-atom? mod-arg))
(raise (er-mk-error-marker (er-mk-atom "badarg")))
(dict-has? (er-modules-get) (get mod-arg :name))
(er-mk-tuple (list (er-mk-atom "file") (er-mk-atom "loaded")))
:else (er-mk-atom "false")))))
(define er-bif-code-all-loaded
(fn (vs)
(let ((registry (er-modules-get))
(ks (keys (er-modules-get)))
(out (er-mk-nil)))
(for-each
(fn (i)
(let ((k (nth ks (- (- (len ks) 1) i))))
(set! out
(er-mk-cons
(er-mk-tuple
(list (er-mk-atom k) (er-mk-atom "loaded")))
out))))
(range 0 (len ks)))
out)))

313
lib/erlang/vm/dispatcher.sx Normal file
View File

@@ -0,0 +1,313 @@
;; Erlang VM — stub opcode dispatcher (Phase 9).
;;
;; Mimics the OCaml-side EXTENSION shape from
;; plans/sx-vm-opcode-extension.md so opcodes 9b-9g can be designed
;; and tested in SX before 9a (`hosts/ocaml/`) lands the real
;; registration plumbing. When 9a is available, these stubs become
;; the cross-host SX-side mirror of the C/OCaml handlers and the
;; bytecode compiler emits them directly.
;;
;; Opcode IDs follow the plan's tier partition:
;; 0-127 reserved for SX core
;; 128-199 guest extensions (e.g. erlang, lua)
;; 200-247 port-/platform-specific
;;
;; Erlang owns 128-159 for now.
(define er-vm-opcodes (list {}))
(define er-vm-opcodes-get (fn () (nth er-vm-opcodes 0)))
(define
er-vm-opcodes-reset!
(fn () (set-nth! er-vm-opcodes 0 {})))
(define
er-vm-register-opcode!
(fn
(id name handler)
(dict-set! (er-vm-opcodes-get) (str id) {:name name :id id :handler handler})
(er-mk-atom "ok")))
(define
er-vm-lookup-opcode-by-id
(fn
(id)
(let
((reg (er-vm-opcodes-get)) (k (str id)))
(if (dict-has? reg k) (get reg k) nil))))
(define
er-vm-lookup-opcode-by-name
(fn
(name)
(let
((reg (er-vm-opcodes-get))
(ks (keys (er-vm-opcodes-get)))
(found (list nil)))
(for-each
(fn
(i)
(let
((entry (get reg (nth ks i))))
(when
(= (get entry :name) name)
(set-nth! found 0 entry))))
(range 0 (len ks)))
(nth found 0))))
(define er-vm-list-opcodes (fn () (keys (er-vm-opcodes-get))))
;; ── Phase 9i — host opcode-id resolution ────────────────────────
;; When the OCaml `erlang_ext` extension is registered (Phase 9h), the
;; runtime exposes `extension-opcode-id` which maps an "erlang.OP_*"
;; name to the host-assigned id (222-239). We consult it so the SX
;; side and the OCaml side agree on ids; when it returns nil (name not
;; registered) we fall back to the stub-local id.
;;
;; NOTE: this requires a binary with the VM extension mechanism (the
;; vm-ext phase-A..E cherry-pick + Sx_vm_extensions force-link). The
;; loop builds and runs against exactly that binary
;; (hosts/ocaml/_build/default/bin/sx_server.exe). `extension-opcode-id`
;; resolves lazily at call time, so merely loading this file is safe;
;; only invoking the resolver on a binary that lacks the primitive
;; would raise.
(define er-vm-host-opcode-id
(fn (ext-name)
(extension-opcode-id ext-name)))
(define er-vm-effective-opcode-id
(fn (ext-name stub-id)
(let ((host (extension-opcode-id ext-name)))
(cond
(= host nil) stub-id
:else host))))
(define
er-vm-dispatch
(fn
(id operands)
(let
((entry (er-vm-lookup-opcode-by-id id)))
(if
(= entry nil)
(error (str "Erlang VM: unknown opcode id " id))
((get entry :handler) operands)))))
(define
er-vm-dispatch-by-name
(fn
(name operands)
(let
((entry (er-vm-lookup-opcode-by-name name)))
(if
(= entry nil)
(error (str "Erlang VM: unknown opcode name '" name "'"))
((get entry :handler) operands)))))
;; ── Phase 9c — effect opcodes (perform / handle) ────────────────
;; Stub algebraic-effects-style operators. OP_PERFORM raises a tagged
;; exception; OP_HANDLE wraps a thunk in `guard` and catches matching
;; effects, passing the args to the handler. The real specialization
;; (constant-time effect dispatch, single-shot vs multi-shot continuations)
;; lands when 9a integrates.
(define er-vm-effect-marker?
(fn (c effect-name)
(and (= (type-of c) "dict")
(= (get c :tag) "vm-effect")
(= (get c :effect) effect-name))))
(define er-vm-op-perform
(fn (operands)
(raise {:tag "vm-effect" :effect (nth operands 0) :args (nth operands 1)})))
(define er-vm-op-handle
(fn (operands)
(let ((thunk (nth operands 0))
(effect-name (nth operands 1))
(handler (nth operands 2))
(result (list nil))
(caught (list false))
(rethrow (list nil)))
(guard
(c
(:else
(cond
(er-vm-effect-marker? c effect-name)
(do (set-nth! caught 0 true)
(set-nth! result 0 (handler (get c :args))))
:else (set-nth! rethrow 0 c))))
(set-nth! result 0 (thunk)))
(cond
(not (= (nth rethrow 0) nil)) (raise (nth rethrow 0))
:else (nth result 0)))))
;; ── Phase 9d — receive scan opcode ────────────────────────────
;; Selective receive primitive. Scans a mailbox value-list in arrival
;; order; for each value, tries each clause's pattern (binding into
;; env on success); on match returns `{:matched true :index N :body B}`
;; — the caller decides what to do with the index (queue-delete) and
;; the body (eval in the now-mutated env). On miss returns
;; `{:matched false}`, the caller arranges suspension (via OP_PERFORM).
;;
;; Operands: (clauses mbox-list env)
;; clauses — list of {:pattern :guards :body} dicts
;; mbox-list — SX list of message values
;; env — env dict (mutated on match)
(define er-vm-receive-try-clauses
(fn (clauses msg env i)
(cond
(>= i (len clauses)) {:matched false}
:else
(let ((c (nth clauses i)) (snap (er-env-copy env)))
(cond
(and
(er-match! (get c :pattern) msg env)
(er-eval-guards (get c :guards) env))
{:matched true :body (get c :body)}
:else
(do (er-env-restore! env snap)
(er-vm-receive-try-clauses clauses msg env (+ i 1))))))))
(define er-vm-receive-scan-loop
(fn (clauses mbox env i)
(cond
(>= i (len mbox)) {:matched false}
:else
(let ((msg (nth mbox i))
(cr (er-vm-receive-try-clauses clauses msg env 0)))
(cond
(get cr :matched) {:matched true :index i :body (get cr :body)}
:else (er-vm-receive-scan-loop clauses mbox env (+ i 1)))))))
(define er-vm-op-receive-scan
(fn (operands)
(er-vm-receive-scan-loop (nth operands 0) (nth operands 1) (nth operands 2) 0)))
;; ── Phase 9e — spawn / send + lightweight scheduler ─────────────
;; Stub register-machine process layout for the eventual fast scheduler.
;; A VM-process is `{:id :registers :mailbox :state :initial-fn :initial-args}`.
;; Registers is a vector (SX list, mutated via set-nth!) — fixed slot count
;; per process so cells don't grow during execution. Mailbox is an SX list.
;; State is one of "runnable" / "waiting" / "dead". This sits PARALLEL to
;; the existing `er-scheduler` (which is the language-level scheduler) —
;; the VM scheduler will eventually take over once 9a integrates and
;; bytecode-compiled Erlang runs against it.
(define er-vm-procs (list {}))
(define er-vm-procs-get (fn () (nth er-vm-procs 0)))
(define er-vm-procs-reset!
(fn () (do (set-nth! er-vm-procs 0 {}) (set-nth! er-vm-next-pid 0 0))))
(define er-vm-next-pid (list 0))
(define er-vm-proc-new!
(fn (initial-fn initial-args)
(let ((pid (nth er-vm-next-pid 0)))
(set-nth! er-vm-next-pid 0 (+ pid 1))
(let ((proc
{:id pid
:registers (list nil nil nil nil nil nil nil nil)
:mailbox (list)
:state "runnable"
:initial-fn initial-fn
:initial-args initial-args}))
(dict-set! (er-vm-procs-get) (str pid) proc)
pid))))
(define er-vm-proc-get (fn (pid) (get (er-vm-procs-get) (str pid))))
(define er-vm-proc-send!
(fn (pid msg)
(let ((proc (er-vm-proc-get pid)))
(cond
(= proc nil) false
:else
(do
(dict-set! proc :mailbox (append (get proc :mailbox) (list msg)))
(when (= (get proc :state) "waiting")
(dict-set! proc :state "runnable"))
true)))))
(define er-vm-proc-mailbox (fn (pid) (get (er-vm-proc-get pid) :mailbox)))
(define er-vm-proc-state (fn (pid) (get (er-vm-proc-get pid) :state)))
(define er-vm-proc-count (fn () (len (keys (er-vm-procs-get)))))
(define er-vm-op-spawn
(fn (operands)
(er-vm-proc-new! (nth operands 0) (nth operands 1))))
(define er-vm-op-send
(fn (operands)
(er-vm-proc-send! (nth operands 0) (nth operands 1))))
;; ── Phase 9f — hot-BIF dispatch table ──────────────────────────
;; Specialized opcodes for the BIFs that the bytecode compiler emits
;; on hot call sites. The handler is the underlying `er-bif-*` impl
;; directly — same `(vs)` signature as the dispatcher uses for
;; operands, so the cost is the opcode-id → handler hop with no
;; registry-key string lookup. Cold BIFs continue going through the
;; general path (`er-apply-bif` / `er-lookup-bif`).
;;
;; Opcodes 136-159 reserved for hot BIFs.
;; ── Phase 9b — pattern-match opcodes ────────────────────────────
;; Each handler takes a list (pattern-ast value env) and returns
;; true/false, mutating env on success (same contract as the
;; existing er-match-tuple / er-match-cons / er-match-binary).
;; Wire these as wrappers for now; the real opcodes will eventually
;; have register-machine semantics and skip the AST-walk overhead.
(define
er-vm-register-erlang-opcodes!
(fn
()
(er-vm-register-opcode!
128
"OP_PATTERN_TUPLE"
(fn
(operands)
(er-match-tuple
(nth operands 0)
(nth operands 1)
(nth operands 2))))
(er-vm-register-opcode!
129
"OP_PATTERN_LIST"
(fn
(operands)
(er-match-cons
(nth operands 0)
(nth operands 1)
(nth operands 2))))
(er-vm-register-opcode!
130
"OP_PATTERN_BINARY"
(fn
(operands)
(er-match-binary
(nth operands 0)
(nth operands 1)
(nth operands 2))))
(er-vm-register-opcode! 131 "OP_PERFORM" er-vm-op-perform)
(er-vm-register-opcode! 132 "OP_HANDLE" er-vm-op-handle)
(er-vm-register-opcode! 133 "OP_RECEIVE_SCAN" er-vm-op-receive-scan)
(er-vm-register-opcode! 134 "OP_SPAWN" er-vm-op-spawn)
(er-vm-register-opcode! 135 "OP_SEND" er-vm-op-send)
;; Phase 9f — hot BIFs
(er-vm-register-opcode! 136 "OP_BIF_LENGTH" er-bif-length)
(er-vm-register-opcode! 137 "OP_BIF_HD" er-bif-hd)
(er-vm-register-opcode! 138 "OP_BIF_TL" er-bif-tl)
(er-vm-register-opcode! 139 "OP_BIF_ELEMENT" er-bif-element)
(er-vm-register-opcode! 140 "OP_BIF_TUPLE_SIZE" er-bif-tuple-size)
(er-vm-register-opcode! 141 "OP_BIF_LISTS_REVERSE" er-bif-lists-reverse)
(er-vm-register-opcode! 142 "OP_BIF_IS_INTEGER" er-bif-is-integer)
(er-vm-register-opcode! 143 "OP_BIF_IS_ATOM" er-bif-is-atom)
(er-vm-register-opcode! 144 "OP_BIF_IS_LIST" er-bif-is-list)
(er-vm-register-opcode! 145 "OP_BIF_IS_TUPLE" er-bif-is-tuple)
(er-mk-atom "ok")))
(er-vm-register-erlang-opcodes!)

View File

@@ -10,7 +10,9 @@ End-state goal: spawn a million processes, run the classic **ring benchmark**, p
- **Conformance:** not BEAM-compat. "Looks like Erlang, runs like Erlang, not byte-compatible." We care about semantics, not BEAM bug-for-bug.
- **Test corpus:** custom — ring, ping-pong, fibonacci-server, bank-account-server, echo-server, plus ~100 hand-written tests for patterns/guards/BIFs. No ISO Common Test.
- **Binaries:** basic bytes-lists only; full binary pattern matching deferred.
- **Hot code reload, distribution, NIFs:** out of scope entirely.
- **Distribution, NIFs:** out of scope entirely.
- **Hot code reload (Phase 7):** in scope — driven by [fed-sx](../plans/fed-sx-design.md) (section 17.5) which needs federated modules to be re-loaded without restarting the scheduler.
- **FFI BIFs (Phase 8):** in scope — Erlang code needs `crypto:hash`, `cid:from_bytes`, `file:read_file`, `httpc:request`, `sqlite:exec` to participate in fed-sx. A general FFI BIF registry replaces today's hard-coded BIF dispatch.
## Ground rules
@@ -95,10 +97,126 @@ Core mapping:
- [x] ETS-lite (in-memory tables via SX dicts) — **13 new eval tests**; `ets:new/2`, `insert/2`, `lookup/2`, `delete/1-2`, `tab2list/1`, `info/2` (size); set semantics with full Erlang-term keys
- [x] More BIFs — target 200+ test corpus green — **40 new eval tests**; 530/530 total. New: `abs/1`, `min/2`, `max/2`, `tuple_to_list/1`, `list_to_tuple/1`, `integer_to_list/1`, `list_to_integer/1`, `is_function/1-2`, `lists:seq/2-3`, `lists:sum/1`, `lists:nth/2`, `lists:last/1`, `lists:member/2`, `lists:append/2`, `lists:filter/2`, `lists:any/2`, `lists:all/2`, `lists:duplicate/2`
### Phase 7 — hot code reload
Driven by **fed-sx** (see `plans/fed-sx-design.md` §17.5): federated modules must be replaceable at runtime without bouncing the scheduler. Classic OTP behaviour: two versions per module ("current" and "old"), local calls stick to the version the process started with, cross-module (`M:F(...)`) calls always resolve to the current version, and `purge` kills any process still running old code.
- [x] Module version slot: `er-modules` entry becomes `{:current MOD-ENV :old MOD-ENV-or-nil :version INT}`; bump version on each load — **13 new runtime tests** (543/543 total)
- [x] `code:load_binary/3` (the canonical reload BIF) — re-parses module source, swaps `:current``:old`, installs new env as `:current`; returns `{module, Name}` or `{error, Reason}` (badarg / badfile / module_name_mismatch). **+8 eval tests** (551/551 total). `code:load_file/1` is a thin filesystem wrapper around this and lands once `file:read_file/1` is in (Phase 8).
- [x] `code:purge/1` + `code:soft_purge/1` — purge clears `:old` slot and kills any process whose `:initial-fun` env identity matches the old env (returns `true` if there was old code, `false` if there wasn't). soft_purge: refuses (returns `false`, leaves `:old` intact) if any process is still pinned to the old env; otherwise clears and returns `true`. **+10 eval tests** (561/561 total). Caveat: a true "lingering on old code" test needs `spawn/3` (still stubbed) or `fun M:F/A` syntax (not parsed) — anonymous `fun () -> M:F() end` closures capture the caller's env, not the module's, and cross-module calls always resolve to `:current`. Current tests therefore exercise the return-value matrix but not the kill path.
- [x] `code:which/1`, `code:is_loaded/1`, `code:all_loaded/0` — introspection. **+10 eval tests** (571/571 total). Return-value contract: `which``loaded` / `non_existing` (since we have no filesystem path); `is_loaded``{file, loaded}` / `false`; `all_loaded` → list of `{Module, loaded}` tuples. Non-atom Mod raises `error:badarg`.
- [x] Cross-module call `M:F(...)` dispatches to `:current`; local calls inside a module body keep using the env they closed over so a running process finishes its current function with the version it started with — **+6 eval tests** verifying the property end-to-end (577/577 total). No implementation change: `er-apply-user-module` already routes through `er-module-current-env`, and `er-mk-fun` captures its env by reference so closures created under v1 retain v1's `mod-env` even after the slot bumps to v2.
- [x] Tests: load v1 → spawn → load v2 → cross-module call hits v2 → local call inside v1 process keeps v1 semantics until function returns → purge kills v1 procs → soft_purge refuses while v1 procs alive — **+5 capstone eval tests** (582/582 total). Required extending `er-procs-on-env` from raw identity match to `er-env-derived-from?` (an env "comes from" mod-env if it IS mod-env or contains a value that's a fun closed over mod-env), because `er-apply-fun-clauses` does `er-env-copy closure-env` before binding params — so the spawned-from-inside-module fun's `:env` is a fresh dict, not mod-env. Test ladder runs as one single `erlang-eval-ast` program (every call to `ev` resets the scheduler via `er-sched-init!`, so Pid handles must live within one program).
### Phase 8 — FFI BIF mechanism + standard libs
Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in `transpile.sx`) with a runtime-extensible **BIF registry**. Each registry entry is `{:module :name :arity :fn :pure?}`. Standard libs are then registered at boot, and fed-sx can register new BIFs from `.sx` files. Includes the marshalling layer (Erlang term ↔ SX value) so wrappers stay one-liners.
- [x] BIF registry: `er-bif-registry` global dict keyed by `"Module/Name/Arity"`, with `er-register-bif!`/`er-register-pure-bif!`/`er-lookup-bif`/`er-list-bifs`/`er-bif-registry-reset!` helpers — **+18 runtime tests** (600/600 total). Entries are `{:module :name :arity :fn :pure?}`. Arity is part of the key so `m:f/1` and `m:f/2` are independent. Re-registering the same key replaces the previous entry; reset clears.
- [x] Migrate existing local + remote BIFs (length/hd/tl/lists:*/io:format/ets:*/etc.) onto the registry; delete the giant `cond` dispatch in `er-apply-bif`/`er-apply-remote-bif`. Conformance held at **600/600** after migration (baseline was 600, not the plan-text's 530 — the text was authored before Phase 7 work added rows). 67 builtin registrations across `erlang`/`lists`/`io`/`ets`/`code` modules; multi-arity BIFs (`is_function`, `spawn`, `exit`, `io:format`, `lists:seq`, `ets:delete`) register once per arity, all pointing at the same impl which dispatches on `(len vs)` internally. The four per-module cond dispatchers (`er-apply-lists-bif`, `er-apply-io-bif`, `er-apply-ets-bif`, `er-apply-code-bif`) are deleted. `er-apply-bif` and `er-apply-remote-bif` are now ~5-line registry lookups; user modules still win precedence over the registry.
- [x] Term-marshalling helpers: `er-of-sx` (SX → Erlang) and `er-to-sx` (Erlang → SX). atom ↔ symbol, nil ↔ `()`, cons → list, tuple → list (one-way; tuples flatten), binary ↔ SX string, integer / float / boolean passthrough. **+23 runtime tests** (623/623 total). Erlang maps (`dict ↔ map`) deferred — Erlang map term not implemented in this port; will land when `#{}` syntax does. Pids, refs, funs pass through unchanged. SX strings on the way back become Erlang binaries (most useful FFI return shape).
- [ ] `crypto:hash/2`**BLOCKED** (no `sha256`/`sha512`/`blake3` primitive in this SX runtime). See Blockers.
- [ ] `cid:from_bytes/1`, `cid:to_string/1`**BLOCKED** (needs `crypto:hash/2`). See Blockers.
- [x] `file:read_file/1`, `file:write_file/2`, `file:delete/1`**+10 eval tests** (633/633 total). Returns `{ok, Binary}` / `ok` / `{error, Reason}` where Reason is `enoent`/`eacces`/`enotdir`/`eisdir`/`posix_error` (classified from the SX `file-read`/`-write`/`-delete` exception string). Path accepts SX string, Erlang binary, or Erlang char-code list. `file:list_dir/1` deferred — no directory-listing primitive in this SX runtime; see Blockers.
- [ ] `httpc:request/4`**BLOCKED** (no HTTP client primitive). See Blockers.
- [ ] `sqlite:open/1`, `sqlite:close/1`, `sqlite:exec/2`, `sqlite:query/2`**BLOCKED** (no SQLite primitive). See Blockers.
- [x] Tests: 1 round-trip per BIF; suite name `ffi`; conformance scoreboard auto-picks it up — **+14 ffi tests** at 637/637 total. Suite covers the 3 implemented file BIFs (9 tests: write-ok, read-ok-tag, payload-is-binary, byte_size content, missing-enoent, bad-path-enoent, binary-payload round-trip, delete-ok, read-after-delete-enoent) plus 5 negative asserts (one per blocked BIF — `crypto:hash`/`cid:from_bytes`/`file:list_dir`/`httpc:request`/`sqlite:exec`) so this suite fails fast if a future iteration adds a wrapper without registering proper tests. Target "+40 ffi tests" was relative to the original 5-BIF-family plan; with 5 of those families blocked on host primitives, the achievable count is 14 — the suite scaffolding is what matters and is ready to accept the remaining tests when the primitives land.
### Phase 9 — specialized opcodes (the BEAM analog)
**Driver:** Erlang-on-SX going through the general-purpose CEK machine has architectural perf ceilings (call/cc per receive, env-copy per call, mailbox rebuild on delete). The fix is specialized bytecode opcodes that bypass the general machinery for hot Erlang operations. Targets: 100k+ message hops/sec, 1M-process spawn in under 30sec. Layered perf strategy: Layer 1 (this) = specialized opcodes; Layer 2 (Phase 10, deferred) = multi-core scheduler.
**Architectural note:** opcodes get developed in `lib/erlang/vm/` (in scope). The **opcode extension mechanism in `hosts/ocaml/`** (Phase 9a) is **out of scope** for this loop — log as Blocker until a session that owns `hosts/` lands it. Sub-phases 9b-9g design and test opcodes against a stub dispatcher in the meantime; integrate when 9a is available.
**Shared-opcode discipline:** opcodes that another language port could plausibly use (pattern match, perform/handle, record access) get prepared for **chiselling out to `lib/guest/vm/`** when a second use materialises. Same lib/guest pattern, applied at the bytecode layer. Don't pre-extract; do annotate candidates in commit messages.
- [x] **9a — Opcode extension mechanism****INTEGRATED** (scope widened by user 2026-05-15: hosts/ in scope, merging back). Cherry-picked the 5 vm-ext commits (phases A-E: dispatch fallthrough for opcodes ≥200, `Sx_vm_extension` interface, `Sx_vm_extensions` registry, `extension-opcode-id` SX primitive, JIT skip path) onto loops/erlang. Force-linked `Sx_vm_extensions` into `bin/sx_server.ml` so its module-init runs (was dead-code-eliminated — only `run_tests` referenced it). `extension-opcode-id` is now live in the runtime: returns the registered opcode id, or nil for unknown names. Built clean; conformance held at **709/709** on the freshly built binary. Design: `plans/sx-vm-opcode-extension.md`.
- [x] **9b — `OP_PATTERN_TUPLE` / `OP_PATTERN_LIST` / `OP_PATTERN_BINARY`****+19 vm tests** (656/656 total). Stub dispatcher in `lib/erlang/vm/dispatcher.sx` mirrors the OCaml extension shape from `plans/sx-vm-opcode-extension.md`: `er-vm-register-opcode!`/`er-vm-lookup-opcode-by-id`/`er-vm-lookup-opcode-by-name`/`er-vm-dispatch`. Opcode IDs 128 (TUPLE), 129 (LIST), 130 (BINARY) per the guest-tier partition (128-199). Handlers are thin wrappers over the existing `er-match-tuple`/`er-match-cons`/`er-match-binary` for now; the real specialization (skip AST walk, register-machine operands) lands when 9a integrates. Conformance must remain unchanged — **656/656** preserved. Candidate for chiselling to `lib/guest/vm/match.sx` once a second port (Prolog? miniKanren?) wants the same opcodes.
- [x] **9c — `OP_PERFORM` / `OP_HANDLE`****+9 vm tests** (665/665 total). Stubs in `lib/erlang/vm/dispatcher.sx`: `OP_PERFORM` (id 131) raises `{:tag "vm-effect" :effect <name> :args <args>}`; `OP_HANDLE` (id 132) wraps a thunk in `guard`, catches matching effects (by `:effect` name), passes args to the handler, returns the handler's result. Non-matching effects rethrow to outer handlers (verified by a nested-handle test). Pure Erlang `receive` interface unchanged; this is the substrate for the eventual call/cc-free implementation when 9a integrates. Candidate for chiselling (Scheme call/cc, OCaml 5 effects, miniKanren all want the same shape).
- [x] **9d — `OP_RECEIVE_SCAN`****+10 vm tests** (675/675 total). Stub at id 133 in `lib/erlang/vm/dispatcher.sx`. Operand contract: `(clauses mbox-list env)` where each clause is `{:pattern :guards :body}`, mbox-list is a plain SX list (not a queue — caller does queue→list before invoking and queue-delete after). Walks mbox in arrival order; tries each clause per message; first match returns `{:matched true :index N :body B}` (env mutated with bindings, body NOT evaluated — caller chooses when); no match returns `{:matched false}`. Pure pattern scan; suspension is the caller's job (compose with OP_PERFORM "receive-suspend" once 9a integrates). The real opcode will skip the AST walk by JIT-compiling each clause's match expr; this stub re-uses `er-match!` for correctness.
- [x] **9e — `OP_SPAWN` / `OP_SEND` + lightweight scheduler****+16 vm tests** (691/691 total). Stubs at ids 134 (SPAWN) and 135 (SEND) in `lib/erlang/vm/dispatcher.sx`, plus the VM-process registry: `er-vm-procs` (dict pid → proc record), `er-vm-next-pid`, `er-vm-procs-reset!`, `er-vm-proc-new!`/`get`/`send!`/`mailbox`/`state`/`count`. Process record shape is the register-machine layout the real scheduler will use: `{:id :registers (list of 8 nil slots) :mailbox (SX list) :state ("runnable"/"waiting"/"dead") :initial-fn :initial-args}`. OP_SPAWN returns a numeric pid and allocates a fresh record; OP_SEND appends to the target's mailbox, flipping `:state` from "waiting" → "runnable" if needed (returns true on success, false on unknown pid — no crash). Sits parallel to `er-scheduler` (the language-level scheduler from Phase 3); the real VM scheduler will take over once 9a integrates and Erlang programs compile to bytecode. Perf targets in the bullet (spawn <50µs, send <5µs) defer to the integration step.
- [x] **9f — BIF dispatch table****+18 vm tests** (709/709 total). 10 hot BIFs get their own opcode IDs (136-145) in `lib/erlang/vm/dispatcher.sx`: `OP_BIF_LENGTH`, `OP_BIF_HD`, `OP_BIF_TL`, `OP_BIF_ELEMENT`, `OP_BIF_TUPLE_SIZE`, `OP_BIF_LISTS_REVERSE`, `OP_BIF_IS_INTEGER`, `OP_BIF_IS_ATOM`, `OP_BIF_IS_LIST`, `OP_BIF_IS_TUPLE`. Each opcode's handler IS the underlying `er-bif-*` impl directly (no registry-string-lookup), so cost is opcode-id → handler one-hop. Cold BIFs continue through `er-apply-bif` / `er-lookup-bif` as before. IDs 136-159 reserved for future hot-BIF additions.
- [x] **9h — `erlang_ext.ml`** — OCaml extension at `hosts/ocaml/lib/extensions/erlang_ext.ml` registering the 18-opcode Erlang namespace (ids **222-239**, names `erlang.OP_*` mirroring the SX stub dispatcher). Registered at sx_server startup via `Erlang_ext.register ()` (guarded against double-register Failure). `extension-opcode-id "erlang.OP_PATTERN_TUPLE"` → 222 … `OP_BIF_IS_TUPLE` → 239, unknown → nil. Handlers raise a descriptive not-wired `Eval_error` (bytecode emission is a later phase; SX stub dispatcher remains the working specialization path) — keeps the extension honest rather than silently corrupting the VM stack. id range 222+ dodges test_reg (210/211) + test_ext (220/221) so all three coexist in run_tests. **+5 OCaml ext tests** (run_tests `Suite: extensions/erlang_ext`); Erlang conformance held **709/709**.
- [x] **9i — wire SX dispatcher to real ids**`lib/erlang/vm/dispatcher.sx` gains `er-vm-host-opcode-id` (thin `extension-opcode-id` wrapper) and `er-vm-effective-opcode-id name stub-id` (host id when non-nil, else stub-id). `extension-opcode-id` resolves lazily at call time so loading the file is safe even on a binary lacking the primitive; only invoking the resolver there would raise (documented prereq — the loop builds + runs against the binary that has it). **+6 vm tests** (715/715): OP_PATTERN_TUPLE→222, OP_BIF_IS_TUPLE→239, unknown→nil, effective prefers host (OP_BIF_LENGTH→230), effective falls back to stub on nil (999), and a sweep asserting the whole 18-name namespace maps contiguously to 222..239. Stub-local ids (128-145) registration untouched so the prior 72 vm tests stay green.
- [x] **9g — Conformance + perf bench** — Ran `lib/erlang/bench_ring.sh 10 100 500 1000` on the integrated binary (9a+9h+9i built in): 11/36/35/31 hops/s — **unchanged from the pre-integration baseline**, which is the correct expected result and doubles as a no-regression proof (the full extension wiring added zero per-hop cost). Conformance **715/715** on the same binary. Numbers recorded in `lib/erlang/bench_ring_results.md` with the rationale. The ~3000×/~1000× targets are gated on Phase 10 (bytecode emission) — the compiler doesn't emit `erlang.OP_*` yet, so every hop still takes the general CEK path. 9g's deliverable (honest measurement on the integrated binary) is complete.
### Phase 10 — bytecode emission (unlock the speedup)
The Phase 9 opcodes are registered, tested, and bridged SX↔OCaml, but inert: nothing emits them. Phase 10 makes the speedup real.
- [ ] **10a — compiler emits `erlang.OP_*` at hot sites****BLOCKED on `lib/compiler.sx` ownership (out of this loop's scope).** Architecture fully mapped (2026-05-15, see Blockers + design below). The correct implementation site is `lib/compiler.sx`'s `compile-call` — it must recognize calls to the Erlang runtime-helper functions that have a registered `erlang.OP_*` opcode and emit that opcode (via the already-live `extension-opcode-id` primitive) instead of a generic CALL. This is **generic shared compiler infrastructure** (any guest port — Prolog, Lua — would use the same intrinsic mechanism), explicitly excluded by the ground rules ("Don't edit lib/ root"; not in the widened hosts/-only scope). Concrete sub-steps for the owning session:
- **10a.1** Add an *intrinsic registry* to `lib/compiler.sx`: a dict `callee-name → extension-opcode-name`, populated by guests at load (e.g. Erlang registers `er-bif-length → "erlang.OP_BIF_LENGTH"`, `er-match-tuple → "erlang.OP_PATTERN_TUPLE"`, …).
- **10a.2** In `compile-call`: if the resolved callee is in the intrinsic registry AND `(extension-opcode-id name)` is non-nil, compile the args normally (push left→right) then emit the single opcode byte instead of `CALL`. Fall back to generic CALL when the opcode is absent (graceful on binaries without the extension).
- **10a.3** Define the operand/stack contract per opcode class and make `erlang_ext.ml`'s control handlers (222-229) match it (pattern opcodes need the pattern AST as a constant-pool operand + the scrutinee on the stack; perform/handle/receive/spawn/send need OCaml↔SX runtime-state access — see 10b-control note).
- **10a.4** Conformance must stay green; add bytecode-emission tests (compile an Erlang fn, disassemble, assert the opcode appears at the hot site).
Until a session owning `lib/compiler.sx` lands 10a.1-10a.2, the speedup cannot be realized from this loop. The BIF half of 10b (operand-less stack ops) is fully done and *would* light up immediately once emission exists.
- [~] **10b — real `erlang_ext.ml` handlers****10 of 18 real** (ALL BIF opcodes done: 230-239). Latest: `OP_BIF_ELEMENT` (233, pops Tuple-then-Index, 1-indexed, range-checked) and `OP_BIF_LISTS_REVERSE` (235, builds a fresh reversed cons chain in OCaml). Re-scoping correction: ELEMENT/REVERSE were earlier mislabelled "gated on 10a" — they're pure stack transforms (no bytecode operands; element/2 just pops 2), so they landed now. **21 e2e run_tests** total. Remaining 8 stubs are the genuine control/structural opcodes that DO need compiler-defined operands + runtime state: `OP_PATTERN_TUPLE/LIST/BINARY` (222-224), `OP_PERFORM/HANDLE` (225-226), `OP_RECEIVE_SCAN` (227), `OP_SPAWN/SEND` (228-229). not-wired guard repointed to 222. 715/715 unaffected. — earlier note: 8 of 18 real (all hot-BIFs done). Real register-machine handlers: `OP_BIF_LENGTH` (230, cons-walk), `OP_BIF_HD` (231), `OP_BIF_TL` (232), `OP_BIF_TUPLE_SIZE` (234, handles List + ListRef `:elements`), `OP_BIF_IS_INTEGER` (236, `Integer _`), `OP_BIF_IS_ATOM` (237), `OP_BIF_IS_LIST` (238, cons|nil), `OP_BIF_IS_TUPLE` (239) — all operate on the tagged-Dict value repr, push Erlang bool atoms via a `mk_atom` helper, raise on type errors. **15 end-to-end run_tests tests** (build real bytecode `[CONST i; op; RETURN]` with list/tuple/atom constants, assert via `Sx_vm.execute_module`). Still `not_wired`: the 8 control opcodes — `OP_PATTERN_TUPLE/LIST/BINARY` (222-224), `OP_PERFORM/HANDLE` (225-226), `OP_RECEIVE_SCAN` (227), `OP_SPAWN/SEND` (228-229) — plus `OP_BIF_ELEMENT` (233, needs 2 operands) and `OP_BIF_LISTS_REVERSE` (235). not-wired guard repointed to 233. 715/715 conformance unaffected (VM-bytecode path only; interpreter untouched). Remaining 10b: the 10 control/structural handlers.
- [ ] **10c — perf validation**: re-run `bench_ring.sh`; target 100k+ hops/sec at N=1000, 1M-process spawn < 30s; record in `bench_ring_results.md`. Conformance must stay green.
**Acceptance:** ring benchmark hits the 100k hops/sec target. All prior phase tests pass. Two opcodes chiselled to `lib/guest/vm/` (or annotated as candidates with a written rationale).
## Progress log
_Newest first._
- **2026-05-18 FIXED merge-blocking regression: cyclic-env hang in `er-env-derived-from?`** — A trial merge of loops/erlang → architecture regressed Erlang **715/715 → 0/0** on the architecture binary. Bisected: not loader semantics, not a uniform slowdown — pinpointed to the *single* Phase 7 capstone test (eval.sx lines 1314-1346; prefix-1313 was byte-identical speed on both binaries, 27s, prefix-1346 was 28s on loops vs >5min/hung on architecture). Isolated further: spawn+reload alone 0.6s, reload+purge alone 0.3s, but spawn+reload+**purge over forever-blocked procs** hung. Root cause: `er-env-derived-from?` (transpile.sx, used by `code:purge`/`soft_purge` via `er-procs-on-env`) compared closure envs with `(= env target-env)`. loops/erlang's evaluator implements dict `=` as **object identity**; architecture's 131-commit-newer evaluator changed it to **structural deep equality**. Erlang closure envs are large and **cyclic** (a module fun's `:env` transitively references the fun), so structural `=` over them never terminates. Fix: use `identical?` (pointer-identity predicate, present + consistent `(true false)` on *both* binaries) — the actually-intended semantics and host-independent. Verified: full eval.sx on the architecture binary >200s/hung → **59s**; full 10-suite conformance on the architecture binary now **715/715** (eval 385/385, vm 78/78, ffi 14/14, all process suites green). loops/erlang behaviour unchanged (`identical?` ≡ its old `=`-identity). One-file change (`lib/erlang/transpile.sx`, +7/-2). The merge can now be re-attempted; this was the sole blocker.
- **2026-05-15 Phase 10a — architecture traced, scoped, blocked on `lib/compiler.sx`** — Investigation-only iteration (correctly: faking compiler emission within scope is impossible and would be dishonest). Traced the full JIT path: `sx_vm.ml`'s `jit_compile_lambda` (the ref set at line 1206) invokes the SX-level `compile` from `lib/compiler.sx` via the CEK machine — that is the only SX→bytecode producer. Erlang's hot helpers are ordinary SX functions in `transpile.sx` that get JIT-compiled through exactly this path, so emitting `erlang.OP_*` means teaching `compiler.sx`'s `compile-call` to recognize them as intrinsics and emit the extension opcode (the file's own docstring already anticipates this — "Compilers call `extension-opcode-id` to emit extension opcodes" — designed but unimplemented; grep confirms zero `extension-opcode-id` uses in `compiler.sx`). `lib/compiler.sx` is lib-root: excluded by ground rules and the widened scope (editing it changes every guest's JIT — must be a shared-compiler session, not this loop). Recorded a precise Blockers entry + decomposed 10a into four numbered sub-steps (10a.1 intrinsic registry, 10a.2 `compile-call` emission with graceful CALL fallback, 10a.3 operand/stack contract for control opcodes, 10a.4 bytecode-emission tests) so the owning session can execute directly. Key payoff documented: all 10 BIF handlers (230-239) are already real, so they light up the instant 10a.1-10a.2 land — zero further Erlang-side work for the BIF speedup. No code changed; conformance unverified-but-untouched at **715/715** (no source touched). Phase 10's loop-reachable work (10b BIF half) is complete; the rest is correctly blocked and fully actionable elsewhere.
- **2026-05-15 Phase 10b — ELEMENT + LISTS_REVERSE real; all 10 BIF opcodes done** — Re-examined the earlier "gated on 10a" claim for ELEMENT/REVERSE and found it wrong: both are pure stack transforms with no need for bytecode operands (`element/2` just pops Tuple then Index off the VM stack; `lists:reverse/1` pops one list). Implemented both as real handlers in `erlang_ext.ml`. `OP_BIF_ELEMENT` (233): pops Tuple (TOS) then Index, handles List/ListRef `:elements`, 1-indexed, raises on out-of-range or wrong arg types. `OP_BIF_LISTS_REVERSE` (235): walks the cons chain building a fresh reversed one via local `mk_cons`/`mk_nil`, raises on improper list. Defined the calling convention for arity-2 ELEMENT: args pushed left→right so stack is `[Index Tuple]`, Tuple on top. 6 new e2e run_tests: element(2/1,{1,2,3}), element out-of-range raises, reverse-then-HD=9, reverse-then-TL-HD=8, reverse-then-LENGTH=3 (composes 3 real opcodes in one bytecode sequence). erlang_ext suite 15→21 PASS, dispatch_count 22. not-wired guard repointed 233→222 (OP_PATTERN_TUPLE — a genuine control opcode still stubbed). **All 10 BIF opcodes (230-239) now real**; the 8 remaining stubs are the true control/structural opcodes (pattern match, perform/handle, receive-scan, spawn/send) which genuinely need 10a's compiler-defined operand encoding + runtime-state access. Erlang conformance **715/715** (interpreter path untouched). 10b is now BIF-complete; the control-opcode half is the real remaining Phase 10 work and is correctly gated on 10a.
- **2026-05-15 Phase 10b — all 8 hot-BIF handlers real** — Built on the vertical slice: added 7 more real register-machine handlers in `erlang_ext.ml` (HD 231, TL 232, TUPLE_SIZE 234, IS_INTEGER 236, IS_ATOM 237, IS_LIST 238, IS_TUPLE 239), joining LENGTH 230. Shared helpers added: `mk_atom` (builds the Erlang bool atom `{tag→atom, name→true|false}`), `er_bool`, `is_tag` (Dict tag predicate). TUPLE_SIZE handles both `List` and `ListRef` `:elements` (Erlang tuples may be built mutably). IS_INTEGER keys off `Sx_types.Integer`. All raise descriptive `Eval_error` on type mismatch. The `op N "name"` stub helper now only covers the 10 remaining control/structural opcodes. 9 new end-to-end run_tests assertions added (HD, TL∘HD, TUPLE_SIZE, IS_INTEGER pos+neg, IS_ATOM, IS_LIST nil-true + tuple-false, IS_TUPLE) — each builds real bytecode with a list/tuple/atom constant and executes via `Sx_vm.execute_module`. erlang_ext suite 6→15 PASS; dispatch_count 12. not-wired guard repointed 231→233 (OP_BIF_ELEMENT, still stubbed — it needs two operands so it's a later sub-step). Erlang conformance **715/715** (the interpreter path is untouched; only the VM-bytecode dispatch gained real handlers). Remaining 10b: pattern tuple/list/binary, perform/handle, receive-scan, spawn/send, element, lists:reverse (10 opcodes).
- **2026-05-15 Phase 10b vertical slice — first real opcode handler, end-to-end VM proof** — Investigation first: confirmed Erlang runs as a pure tree-walking interpreter (`er-eval-expr` over CEK) — there is **no** Erlang→bytecode compiler, so full 10a (compiler emits opcodes) is a multi-week standalone effort, not one iteration. Rather than fake it, de-risked the whole Phase 9/10 architecture with a vertical slice: replaced the `not_wired` raise for `erlang.OP_BIF_LENGTH` (id 230) with a genuine register-machine handler in `erlang_ext.ml` — pops a value, walks the Erlang cons-list representation (`Dict` with `"tag"``"cons"`/`"nil"`, `"head"`, `"tail"`), pushes `Integer` length, raises on improper lists. Added an end-to-end run_tests test that builds real bytecode `[| 1; 0; 0; 230; 50 |]` (CONST idx 0 → OP_BIF_LENGTH → RETURN) with an Erlang `[1,2,3]` in `vc_constants`, executes via `Sx_vm.execute_module`, asserts `Integer 3`. This proves the complete path works: `extension-opcode-id` → bytecode → `Sx_vm` ≥200 dispatch fallthrough → `erlang_ext` handler → correct VM stack result — the load-bearing proof that Phase 9's wiring isn't just stubs. The other 17 opcodes still honestly raise `not_wired`; the prior not-wired guard test was repointed from 230 to 231 (OP_BIF_HD) so it still verifies the honest-failure path. erlang_ext suite 5→6 tests, dispatch_count now 2. Erlang conformance **715/715** unaffected (the new path is VM-bytecode-only; the interpreter path is untouched). 10b marked in-progress `[~]`; remaining: real handlers for the other 17 opcodes + 10a compiler emission. Builds clean via `dune build bin/run_tests.exe bin/sx_server.exe`.
- **2026-05-15 Phase 9g — perf bench recorded on integrated binary; Phase 10 scoped** — Built the fresh `sx_server.exe` (9a+9h+9i wired in), ran `lib/erlang/bench_ring.sh 10 100 500 1000`: 11/36/35/31 hops/s — statistically identical to the pre-9a baseline (11/24/26/29/34). This is the *expected* outcome and the iteration's actual deliverable: it proves the entire extension stack (vm-ext A-E cherry-pick + `Sx_vm_extensions` force-link + `erlang_ext.ml` + SX dispatcher bridge) added **zero per-hop overhead** — a clean no-regression result — while honestly showing the speedup hasn't arrived because the bytecode compiler still doesn't emit `erlang.OP_*` (every hop takes the general CEK path). Updated `bench_ring_results.md` with a "Phase 9g" section: the table + the rationale that unchanged numbers = correct + no-regression. Conformance **715/715** on the integrated binary. Added **Phase 10 — bytecode emission** to the roadmap (10a compiler emits opcodes at hot sites, 10b real register-machine `erlang_ext.ml` handlers replacing the not-wired raises, 10c perf validation against the 100k-hops/1M-spawn targets). Phase 9 is now fully ticked (9a-9i); the actual speedup is honestly deferred to Phase 10 rather than faked. No code change this iteration — measurement + documentation + roadmap.
- **2026-05-15 Phase 9i — SX dispatcher consults host opcode ids** — `lib/erlang/vm/dispatcher.sx` now bridges SX↔OCaml opcode ids. Two new functions: `er-vm-host-opcode-id` (wraps `extension-opcode-id`) and `er-vm-effective-opcode-id name stub-id` (host id if the OCaml `erlang_ext` registered it, else the stub-local id). Key SX-runtime fact established this iteration: symbol resolution is **lazy/call-time**`(define f (fn () (extension-opcode-id "x")))` does NOT raise at load even when the primitive is absent; only calling `f` does. Combined with the earlier findings (guard can't catch undefined-symbol; no symbol-existence reflection), this means graceful in-SX degradation is impossible — so the design instead documents the binary prerequisite and relies on the loop building+running the freshly-built `hosts/ocaml/_build/default/bin/sx_server.exe` (conformance.sh's default, which has the vm-ext mechanism + erlang_ext). Stub-local registration (128-145) deliberately left intact so the 72 pre-existing vm tests don't move. 6 new vm tests: 222/239 lookups, unknown→nil, effective-prefers-host (230), effective-fallback (999), and a contiguity sweep over all 18 `erlang.OP_*` names asserting they map to 222..239 in order. vm suite 72→78. Total **715/715** on the fresh binary. Next: 9g — re-run ring bench, record numbers (note: stubs still wrap existing impls 1-to-1 so numbers won't move until the compiler emits these opcodes — a later phase).
- **2026-05-15 Phase 9h — erlang_ext.ml registered, opcode namespace live** — New `hosts/ocaml/lib/extensions/erlang_ext.ml` modelled on `test_ext.ml`: an `EXTENSION` module `name="erlang"`, per-instance `ErlangExtState` (dispatch counter), 18 opcodes ids 222-239 named `erlang.OP_*` exactly mirroring the SX stub dispatcher. Registered at sx_server startup with a second guarded line in `bin/sx_server.ml` (`try Erlang_ext.register () with Failure _ -> ()` — survives a re-entered server). `include_subdirs unqualified` in `lib/dune` already pulls `lib/extensions/*.ml` into the `sx` lib, so no dune edit needed. Handlers deliberately raise a descriptive `Eval_error` ("bytecode emission not yet wired (Phase 9j) — Erlang runs via CEK; specialization path is the SX stub dispatcher") rather than fake stack ops — the compiler doesn't emit these yet, so an honest loud failure beats silent corruption. Hit and fixed an opcode-id collision: the original 200-217 range clashed with run_tests' inline test_reg (210/211); relocated to 222-239 (clears test_reg + test_ext 220/221, all coexist; production sx_server only registers erlang). 5 new OCaml tests in run_tests `Suite: extensions/erlang_ext`: opcode-id 222 + 239 resolve, unknown→nil, dispatch raises not-wired (substring check, no Str dep since run_tests doesn't link str), dispatch_count state ≥1. Built via `eval $(opam env --switch=5.2.0); dune build bin/run_tests.exe bin/sx_server.exe`. Erlang conformance **709/709** on the rebuilt binary (the broad run_tests 1110 failures are loops/erlang's pre-existing months-old divergence from architecture — run_tests was never built on this branch before; my changes are isolated additive). Next: 9i — wire the SX stub dispatcher to consult `extension-opcode-id`.
- **2026-05-15 Phase 9a integrated — scope widened to hosts/** — User lifted the hosts/ scope restriction ("we are going to merge this back anyhow"). Cherry-picked the 5 `vm-ext` commits (phases A-E) from `loops/sx-vm-extensions` onto `loops/erlang` — only conflict was `plans/sx-vm-opcode-extension.md` (already had architecture's final copy from an earlier iteration; resolved `-X ours`, OCaml files auto-merged clean since loops/erlang never touched hosts/). Discovered `extension-opcode-id` was still "Undefined symbol" even on a fresh build: `Sx_vm_extensions`'s module-init (`install_dispatch` + primitive registration) only runs if the module is linked, and `sx_server.ml` never referenced it (only `run_tests.ml` did), so OCaml dead-code-eliminated it. Fix: added `let () = ignore (Sx_vm_extensions.id_of_name "")` force-link reference near the top of `bin/sx_server.ml`. Rebuilt with `dune build` (opam switch 5.2.0; `dune` not on PATH by default — `eval $(opam env --switch=5.2.0)` first). `extension-opcode-id` now live: returns nil for unregistered names, will return real ids once an extension registers. Conformance **709/709** on the freshly built binary (cherry-picked sx_vm.ml dispatch changes + force-link, zero regressions). 9a checkbox flipped from BLOCKED to INTEGRATED; Blockers entry resolved; added 9h (erlang_ext.ml) + 9i (wire SX dispatcher to real ids) as ordinary in-scope checkboxes, reordered 9g after them. Next: write `hosts/ocaml/lib/extensions/erlang_ext.ml`.
- **2026-05-14 Phase 9g logged as partially BLOCKED — perf bench waits on 9a** — Conformance half satisfied: 709/709 with all Phase 9 stub infrastructure loaded (10 opcode IDs registered, 72 vm-suite tests passing, zero regressions in tokenize/parse/eval/runtime/ring/ping-pong/bank/echo/fib/ffi suites). Perf-bench half can't move forward in this worktree because the stub handlers wrap the existing `er-bif-*` / `er-match-*` / scheduler impls 1-to-1; a ring benchmark with the new opcodes "active" would measure the same 34 hops/s already documented in `bench_ring_results.md`. Updated `bench_ring_results.md` with a Phase 9 status section explaining the pre-integration state (stubs ready, real measurement gated on 9a's bytecode compiler emitting these IDs at hot sites). Blockers entry added pairing 9g with the existing 9a Blocker. No code change; total **709/709** unchanged. Phase 9 stub work (9b-9f) is complete from this loop's vantage point — 9a and 9g remain BLOCKED on a `hosts/ocaml/` iteration.
- **2026-05-14 Phase 9f — hot-BIF opcode table green** — Ten hot BIFs get direct opcode IDs in `lib/erlang/vm/dispatcher.sx` so the bytecode compiler can emit them at hot call sites without paying the registry string-key hash: `OP_BIF_LENGTH (136)`, `OP_BIF_HD (137)`, `OP_BIF_TL (138)`, `OP_BIF_ELEMENT (139)`, `OP_BIF_TUPLE_SIZE (140)`, `OP_BIF_LISTS_REVERSE (141)`, `OP_BIF_IS_INTEGER (142)`, `OP_BIF_IS_ATOM (143)`, `OP_BIF_IS_LIST (144)`, `OP_BIF_IS_TUPLE (145)`. Implementation is one line per opcode: the handler IS the existing `er-bif-*` function directly — same `(vs)` signature as the dispatcher's `(operands)`, so the registration is `(er-vm-register-opcode! ID "NAME" er-bif-FOO)`. IDs 136-159 reserved for future hot-BIF additions; cold BIFs continue through `er-apply-bif`/`er-lookup-bif`. 18 new tests in `tests/vm.sx`: opcode-by-id verification (LENGTH), one positive test per BIF (length on 3-cons, hd, tl-is-cons, element index 2, tuple_size 4, lists:reverse preserves length AND actually reverses [head check], is_integer pos+neg, is_atom pos+neg, is_list pos+nil pos+tuple neg, is_tuple pos+neg), opcode-list-grew-to-16+. vm suite 54 → 72. Total **709/709** (+18 vm). Real perf benefit lands when 9a integrates and the compiler emits these IDs at hot sites.
- **2026-05-14 Phase 9e — OP_SPAWN / OP_SEND + VM-process registry green** — `lib/erlang/vm/dispatcher.sx` gains a parallel mini-runtime distinct from the language-level `er-scheduler`: `er-vm-procs` (dict pid → proc record), `er-vm-next-pid` (counter cell), `er-vm-procs-reset!`, plus six accessors (`er-vm-proc-new!`/`get`/`send!`/`mailbox`/`state`/`count`). Process record shape is the register-machine layout the real bytecode scheduler will use: `{:id :registers (8 nil slots) :mailbox :state :initial-fn :initial-args}` — fixed register width so cells don't grow during execution. Opcode 134 `OP_SPAWN` calls `er-vm-proc-new!` and returns the new pid; 135 `OP_SEND` appends to the target's mailbox and flips a waiting proc back to runnable, returns false for unknown pid (graceful, doesn't crash). 16 new tests in `tests/vm.sx`: opcode-by-id for both, spawn returns 0 / 1 / count=2 / state=runnable / mailbox empty / 8 registers, send returns true, 3-sends preserve arrival order (first + last verified), send to unknown pid returns false, isolation (p1's msgs don't leak into p2), reset clears procs + resets pid counter. vm suite 38 → 54. One gotcha during impl: SX `fn` bodies evaluate ONLY the last expression — `er-vm-procs-reset!` had two `set-nth!` calls back-to-back which silently dropped the first; wrapped in `(do ...)` to fix. Total **691/691** (+16 vm). Real scheduler with per-process scheduling latency and runnable queue is post-9a.
- **2026-05-14 Phase 9d — OP_RECEIVE_SCAN stub green** — Selective-receive primitive at opcode id 133 in `lib/erlang/vm/dispatcher.sx`. Operand contract: `(clauses mbox-list env)` — clauses are AST dicts (`{:pattern :guards :body}`), mbox-list is a plain SX list (queue → list is the caller's job), env is the binding target. Internal helpers `er-vm-receive-try-clauses` (per-message clause walker with env snapshot/restore on failure) and `er-vm-receive-scan-loop` (mailbox walker, arrival order). Match returns `{:matched true :index N :body B}` so the caller can queue-delete at N and then evaluate B in the now-mutated env; miss returns `{:matched false}` so the caller can suspend via OP_PERFORM "receive-suspend". Mirrors the existing `er-try-receive-loop` in `transpile.sx` but doesn't reach into the scheduler — purely VM-level. 10 new tests in `tests/vm.sx`: opcode registered, scan finds match at correct index, scan binds var, body left unevaluated, no-match leaves env untouched, empty mailbox, first-match wins (arrival order — verified by two `{ok, _}` msgs and binding the FIRST value). vm suite 28 → 38. Total **675/675** (+10 vm). When 9a integrates and the real OP_RECEIVE_SCAN compiles clauses into a register-machine match, the existing `er-eval-receive-loop` becomes a one-line dispatch wrapper.
- **2026-05-14 Phase 9c — OP_PERFORM / OP_HANDLE stubs green** — Two new opcodes in `lib/erlang/vm/dispatcher.sx`: id 131 `OP_PERFORM` raises `{:tag "vm-effect" :effect <name> :args <args>}`; id 132 `OP_HANDLE` wraps a thunk in SX `guard`, catches matching effects by `:effect` name, passes the `:args` list to the handler fn, returns the handler's result. New helper `er-vm-effect-marker?` predicates on the dict shape. Non-matching effects rethrow via a small box+rethrow dance (caught with `:else` first, decision deferred to a post-guard cond — re-raise outside the guard's scope so it propagates to outer handlers cleanly). 9 new tests in `tests/vm.sx`: opcode registered for each id; OP_PERFORM raises with correct tag/effect/args; OP_HANDLE catches matching effect; OP_HANDLE returns thunk result when no effect performed; OP_HANDLE rethrows non-matching effect to outer; nested OP_HANDLE blocks separate by effect name (inner handles "a", outer handles "b", performing "b" bypasses inner). vm suite grew 19 → 28 tests. Total **665/665** (+9 vm). Underlying call/cc + raise/guard machinery used by Erlang `receive` is unchanged; this is the shape for the eventual specialization when 9a integrates. Candidate for chiselling to `lib/guest/vm/effects.sx` — Scheme call/cc, OCaml 5 effects, miniKanren all want the same shape.
- **2026-05-14 Phase 9b — stub VM dispatcher + 3 pattern opcodes green** — New `lib/erlang/vm/dispatcher.sx` defines the stub opcode registry mirroring the OCaml `EXTENSION` shape from `plans/sx-vm-opcode-extension.md`: opcodes registered as `{:id :name :handler}` keyed by string-id, looked up by id OR by name, dispatched via `er-vm-dispatch`. Opcode IDs follow the guest-tier partition (128-199 reserved for guest extensions like erlang/lua). Three opcodes registered at load time via `er-vm-register-erlang-opcodes!`: 128 `OP_PATTERN_TUPLE``er-match-tuple`, 129 `OP_PATTERN_LIST``er-match-cons`, 130 `OP_PATTERN_BINARY``er-match-binary`. Operand contract: `(pattern-ast value env)` returning `true`/`false` and mutating env on success — same as the underlying match functions. New `lib/erlang/tests/vm.sx` suite with 19 tests: 7 dispatcher core (registered, lookup by id+name for all three, two miss cases, list-has-3+); 4 OP_PATTERN_TUPLE (match success + var bind, no-match, arity mismatch); 4 OP_PATTERN_LIST (match, head bind, tail-is-cons, no-match on nil); 3 OP_PATTERN_BINARY (match, segment bind, size mismatch); 1 dispatch error (unknown opcode raises). `conformance.sh` updated: added `vm` to SUITES, added `(load "lib/erlang/vm/dispatcher.sx")` before tests and `(load "lib/erlang/tests/vm.sx")` after ffi, added epoch 110 evaluator. AST shape gotcha: er-match! reads `:type` not `:tag`; binary segment `:size` must be an AST node `{:type "integer" :value "8"}` because `er-eval-expr` runs on it. Total **656/656** (+19 vm). 9b complete; 9c (OP_PERFORM/OP_HANDLE) is next.
- **2026-05-14 Phase 9a logged as Blocker — sub-phase 9b is next** — 9a (the opcode extension mechanism in `hosts/ocaml/evaluator/`) is explicitly out-of-scope for this loop per the plan itself (briefing scope rule + 9a's own text). Logged a Blockers entry citing `plans/sx-vm-opcode-extension.md` as the design doc and pointing at the fix path (a `hosts/` session lands the registration shape, then a follow-up here wires the stub dispatcher to the real one). Ticked 9a as DONE because its contract was "Log as Blocker" — that's complete. Sub-phases 9b9g (PATTERN/PERFORM/RECEIVE/SPAWN_SEND/BIF/conformance) now in queue against a stub dispatcher in `lib/erlang/vm/`. No code change this iteration. Total **637/637** unchanged.
- **2026-05-14 Phase 9 scoped + supporting plan files synced** — Copied three plan files from `/root/rose-ash/plans/` (architecture branch) that this worktree was missing: `fed-sx-design.md` (124KB, the substrate design referenced from Phase 7/8 drivers), `fed-sx-milestone-1.md` (33KB, first concrete implementation milestone), `sx-vm-opcode-extension.md` (19KB, the prerequisite for Phase 9a — designs how `lib/<lang>/vm/` registers opcodes against the OCaml SX VM core). Then appended **Phase 9 — specialized opcodes (the BEAM analog)** to `plans/erlang-on-sx.md` covering sub-phases 9a-9g: 9a (opcode extension mechanism in `hosts/ocaml/`) is out-of-scope for this loop (will be logged as a Blocker when the next iteration tries to start it); 9b-9g (PATTERN_TUPLE/LIST/BINARY, PERFORM/HANDLE, RECEIVE_SCAN, SPAWN/SEND + lightweight scheduler, BIF dispatch table, conformance + perf bench) can be designed and tested against a stub dispatcher in the meantime. Targets: ring benchmark 100k+ hops/sec at N=1000 (~3000× speedup), 1M-process spawn under 30sec (~1000× speedup). Plan framing intact for Phase 7/8 — those reflect the actual implementation done in this loop; the architecture-branch framing diverges in language but the work is equivalent. No code touched this iteration. Total **637/637** unchanged.
- **2026-05-14 ffi test suite extracted, conformance scoreboard auto-picks it up** — New `lib/erlang/tests/ffi.sx` with its own counter trio (`er-ffi-test-count`/`-pass`/`-fails`) and `er-ffi-test` helper following the same pattern as runtime/eval/ring tests. The 10 file BIF eval tests from the previous iteration moved out of `eval.sx` (eval dropped from 395 to 385 tests) and into the new suite where they're now 9 tests (consolidated the two write+read tests). `conformance.sh` updated: added `ffi` to `SUITES` array with `er-ffi-test-pass`/`-count` symbols, added `(load "lib/erlang/tests/ffi.sx")` after `fib_server.sx`, added `(epoch 109) (eval "(list er-ffi-test-pass er-ffi-test-count)")`. Scoreboard markdown auto-updated to include the row. Suite also asserts that the 5 blocked BIFs (`crypto:hash`, `cid:from_bytes`, `file:list_dir`, `httpc:request`, `sqlite:exec`) are NOT yet registered — turns a future "added the wrapper but forgot to extend ffi tests" into a hard failure. One eval-comparison gotcha en route: SX's `=` does identity equality on dicts so comparing two separately-constructed `(er-mk-atom "true")` values is false; the existing eval suite has an `eev-deep=` helper that handles this, but the simpler fix in ffi was to extract `:name` via `ffi-nm` and compare strings. Total **637/637** (+14 ffi). Phase 8 fully ticked aside from the BLOCKED bullets — those remain unchecked with explicit Blockers references.
- **2026-05-14 file BIFs landed; crypto/cid/list_dir/http/sqlite blocked on missing host primitives** — Three new FFI BIFs registered in `runtime.sx`: `file:read_file/1`, `file:write_file/2`, `file:delete/1`. Each wraps the SX-host primitive (`file-read`, `file-write`, `file-delete`) inside a `guard` that converts thrown exception strings into Erlang `{error, Reason}` tuples. New helper `er-classify-file-error` does loose pattern-matching on the error message using `string-contains?` to map to standard POSIX-style reasons: `"No such"``enoent`, `"Permission denied"``eacces`, `"Not a directory"``enotdir`, `"Is a directory"``eisdir`, fallback `posix_error`. Filenames coerce through `er-source-to-string` so SX strings, Erlang binaries, and Erlang char-code lists all work. Read returns `{ok, Binary}` (bytes via `(map char->integer (string->list ...))` then `er-mk-binary`); write returns bare `ok`; delete returns bare `ok`. Bootstrap registrations added at the bottom of `er-register-builtin-bifs!` under `"file"`. 10 new eval tests: write-then-read round-trip, ok-tag, payload is binary, byte_size content, missing-file `enoent`, delete-ok, read-after-delete `enoent`, write to non-existent dir `enoent`, binary payload (5 raw bytes) round-trip preserving byte count. Blockers entry added covering five Phase 8 BIFs whose host primitives don't exist in this SX runtime: `crypto:hash/2`, `cid:from_bytes/1`/`to_string/1`, `file:list_dir/1`, `httpc:request/4`, `sqlite:open/exec/query/close`. Fix path documented inline (architecture-branch iteration to register OCaml-side primitives). Total **633/633** (+10 eval).
- **2026-05-14 term-marshalling helpers landed** — `er-to-sx` (Erlang term → SX-native) and `er-of-sx` (SX-native → Erlang term) plus internal helper `er-cons-to-sx-list` (recursive cons-chain walker). All three live in `runtime.sx` next to the BIF registry. Conversion table: atom ↔ symbol via `make-symbol`/`er-mk-atom`; nil ↔ `()`; cons-chain → SX list (recursive marshal of each head); tuple → SX list (one-way — tuples flatten and can't be reconstructed without a tag); binary ↔ SX string (bytes ↔ char codes via `char->integer`/`integer->char`); integer / float / boolean passthrough; opaque types (pid, ref, fun) passthrough. SX strings on the way back become Erlang binaries — the natural FFI return shape. Empty SX list (`type-of` `"nil"`) marshals back to `er-mk-nil`. Edit gotchas during implementation: SX has no `while`, `string-ref`, or `string-length` primitive — used `(map char->integer (string->list s))` for byte extraction and a recursive helper for cons-walking. 23 new runtime tests in `tests/runtime.sx`: 10 covering `er-to-sx` (atom/atom-is-symbol, nil, int / float / bool passthrough, binary→string, cons→list, tuple→list, nested), 8 covering `er-of-sx` (symbol→atom, atom-tag, string→binary, byte content, int passthrough, empty-list→nil, list→cons length, head field), 4 round-trips (int, atom, binary bytes, list length), 1 negative documenting that tuple round-trip flattens to cons. Total **623/623** (+23 runtime).
- **2026-05-14 BIF registry migration complete — cond chains gone** — `er-register-builtin-bifs!` at the end of `runtime.sx` populates the registry with all 67 built-in BIFs in five module namespaces. Pure ops (`length`, `hd`, `tl`, `element`, predicates, arithmetic, list/atom/integer conversions, all of `lists`) registered via `er-register-pure-bif!`; side-effecting ops (`spawn`, `self`, `exit`, `link`/`monitor`/`register`, `process_flag`, `make_ref`, `throw`/`error`, `io:format`, all of `ets`, all of `code`) via `er-register-bif!`. Multi-arity entries: `is_function/1`/`/2`, `spawn/1`/`/3`, `exit/1`/`/2`, `io:format/1`/`/2`, `lists:seq/2`/`/3`, `ets:delete/1`/`/2` — six pairs, twelve registrations, all pointing at the existing arity-dispatching impl. `throw` and `error` are registered with a tiny inline `(fn (vs) (raise ...))` lambda because the original code chained directly through `raise` inside the cond instead of an `er-bif-*` helper. `er-apply-bif` shrinks from a 44-line cond chain to a 5-line registry lookup. `er-apply-remote-bif` becomes a 7-line dispatcher (user-modules-first → registry → error). All four per-module dispatchers (`er-apply-lists-bif`, `er-apply-io-bif`, `er-apply-ets-bif`, `er-apply-code-bif`) deleted — net reduction ~110 lines of cond machinery. One subtle wrinkle: `tests/runtime.sx` calls `er-bif-registry-reset!` near the end of its BIF-registry tests, which would have left subsequent test files (ring, ping-pong, etc.) unable to call `length`/`spawn`/etc. Fix: re-call `er-register-builtin-bifs!` at the bottom of `tests/runtime.sx` to repopulate. Total **600/600** unchanged.
- **2026-05-14 Phase 8 BIF registry foundation** — `lib/erlang/runtime.sx` gains `er-bif-registry` (a `(list {})` mutable cell, same shape as `er-modules`) and five helpers: `er-bif-registry-get`/`er-bif-registry-reset!` (access + reset), `er-bif-key` (format `"Module/Name/Arity"`), `er-register-bif!` and `er-register-pure-bif!` (both upsert; differ only in the `:pure?` flag — pure ones are safe to inline, side-effecting ones go through normal IO), `er-lookup-bif` (returns the entry dict or nil), `er-list-bifs` (registered keys). Entries are `{:module :name :arity :fn :pure?}`. Lookup miss → nil; arity is part of the key so `m:f/1` and `m:f/2` are distinct; re-registering the same key replaces in-place (count stays the same); reset clears. Registry sits alongside `er-modules` in runtime.sx so any other piece of the system can register BIFs without touching the dispatcher — the migration onto this registry (the next checkbox) will rip out the giant cond chains in `er-apply-bif`/`er-apply-remote-bif`. 18 new runtime tests in `tests/runtime.sx`: empty-state, lookup-miss, register-grows-count, lookup-hit-fields (module/name/arity/pure?), fn-invocable, re-register-replaces, pure-flag-true, arity-disambiguation (3 entries for `fake:echo/1`, `fake:echo/2`, `fake:pure/2`), reset-clears, reset-lookup-nil. Total **600/600** (+18 runtime).
- **2026-05-14 Phase 7 capstone green — full hot-reload ladder works end-to-end** — Wires everything from the previous five iterations into one test program: load cap v1 with `start/0` (spawn-from-inside-module) + `loop/0` + `tag/0` → spawn Pid1 (running v1) → load cap v2 → assert `cap:tag()` returns v2 (cross-module dispatch hits `:current`) → spawn Pid2 (running v2) → `code:soft_purge(cap)` returns `false` (refuses while Pid1 is alive on v1's env) → `code:purge(cap)` returns `true` (kills Pid1, clears `:old`) → `code:soft_purge(cap)` returns `true` (clean — no `:old` left). To make this work, `er-procs-on-env` was extended with a new helper `er-env-derived-from?`: a process counts as "running on" mod-env if its `:initial-fun`'s `:env` IS mod-env directly OR contains at least one binding whose value is a fun closed over mod-env. Reason: `er-apply-fun-clauses` always `er-env-copy`s the closure-env before binding params, so a fun created inside a module body has a `:env` that's a *copy* of mod-env, not mod-env itself — the copy still contains the module's other functions as values, each pointing back to the canonical mod-env. The whole ladder runs as a single `erlang-eval-ast` invocation because each call to `ev` resets the scheduler via `er-sched-init!`, wiping any cross-call Pids. 5 capstone tests: v1 tag, v2 tag (cross-mod after reload), soft_purge-refuses, hard purge, soft_purge-clean-after-hard. Total **582/582** (+5 eval). Phase 7 fully ticked.
- **2026-05-14 hot-reload call-dispatch semantics verified** — Tests-only iteration: no implementation change, just six new eval tests that nail down the Erlang semantics already implicit in the current code. (1) `M:F()` after reload returns v2's value (cross-module call hits `:current`). (2) Inside a freshly-loaded body, a bare local call resolves through the new mod-env so a chain `a() -> b()` reflects v2's `b/0`. (3) Calling a fun captured BEFORE reload, whose body uses a local call, returns the v1 value (closure pinned to old mod-env via `er-mk-fun`'s `:env` reference). (4) Calling a fun captured BEFORE reload, whose body uses a cross-module call `M:b()`, returns v2's value (cross-module always wins over closed-over env). (5) Two captured funs from two distinct vintages stay independent — F1() + F2() = 10 + 20 = 30. (6) The slot version counter still bumps even while old captured funs are alive, demonstrating the closure-pinning doesn't block reloads. The "running process finishes its current function with the version it started with" property falls out of fun-as-closure semantics for free — there's no special bookkeeping. Total **577/577** (+6 eval).
- **2026-05-14 code introspection BIFs green** — `code:which/1`, `code:is_loaded/1`, `code:all_loaded/0` added to `er-apply-code-bif` dispatch with three small implementations in `transpile.sx`. `which` and `is_loaded` are dict-lookups on the module registry returning the loaded-marker (atom `loaded`) or the missing-marker (atom `non_existing` for which, atom `false` for is_loaded). Since we don't have a filesystem path representation, the standard `{file, Path}` shape for `is_loaded` becomes `{file, loaded}` — same tuple arity so destructuring code stays portable. `all_loaded` iterates `(keys (er-modules-get))` in reverse (so the result list preserves insertion order after the cons-prepend loop), wrapping each name in a `{Module, loaded}` tuple. **10 new eval tests**: non_existing for absent / loaded after load for which; missing / file-tag / loaded-value for is_loaded; empty / count-after-2-loads / first-entry-tag for all_loaded; badarg for both single-arg BIFs. Two of the all_loaded tests needed an explicit `(er-modules-reset!)` before the measurement because prior tests in the suite leave modules registered (the registry is process-global across the whole epoch session). Total **571/571** (+10 eval).
- **2026-05-14 code:purge/1 + code:soft_purge/1 green** — Two new BIFs in `transpile.sx`: `er-bif-code-purge` and `er-bif-code-soft-purge`, both dispatched through the existing `er-apply-code-bif` cond chain. Shared helper `er-procs-on-env` walks `(er-sched-processes)` and collects pids whose `:initial-fun` is a fun whose `:env` is identical (dict-identity, not structural) to a given env, filtering out already-dead procs. `er-bif-code-purge` looks up the module slot, returns `false` if either the module isn't registered or `:old` is nil; otherwise calls `er-cascade-exit!` on every matching pid with reason `killed`, replaces the slot with a fresh `er-mk-module-slot` that has `:old nil` (current + version preserved), returns `true`. `er-bif-code-soft-purge` returns `true` (treating "no module" / "no old version" as already-purged), else checks for lingering procs and returns `false` (leaving the slot untouched) if any, else clears `:old` and returns `true`. Non-atom Mod raises `error:badarg` from both. **10 new eval tests**: unknown / no-old / after-reload / idempotent for purge; unknown / no-old / clean for soft_purge; badarg for both; one "purge after spawn" test verifying return value (does NOT exercise the kill path — see caveat in plan). Total **561/561** (+10 eval). Implementation cost: 1 dispatch entry, 3 small BIFs, no scheduler changes.
- **2026-05-14 code:load_binary/3 green** — Canonical hot-reload entry point. Adds a `"code"` module branch to `er-apply-remote-bif`'s dispatch; new helpers `er-source-walk-bytes!` and `er-source-to-string` coerce any of {SX string, Erlang binary `<<...>>`, Erlang char-code cons list} to an SX source string before parsing. `er-bif-code-load-binary` is the BIF itself: validates `Mod` is an atom (`{error, badarg}` else), coerces source (`{error, badarg}` on unrecognised shape), wraps `erlang-load-module` in `guard` to convert parse failures into `{error, badfile}`, checks the parsed `-module(Name).` matches the BIF's first arg (`{error, module_name_mismatch}` else), returns `{module, Mod}`. Reload reuses the Phase-7 slot logic from the previous iteration so calling `code:load_binary(m, _, v2_source)` after `code:load_binary(m, _, v1_source)` bumps the slot to version 2 with v1 sitting in `:old`. 8 new eval tests: ok-tag/ok-name on first load, immediate cross-module call hits new env, reload-and-call returns v2 result, name-mismatch errors with both tag and reason, garbage source yields badfile, non-atom Mod is badarg. Total **551/551** (+8 eval). `code:load_file/1` deferred until `file:read_file/1` lands in Phase 8 (it's just a wrapper that reads bytes from disk then calls `load_binary`).
- **2026-05-14 Phase 7 module-version slot landed** — `er-modules` entries are now `{:current MOD-ENV :old MOD-ENV-or-nil :version INT :tag "module"}` instead of bare mod-env dicts. New helpers in `runtime.sx`: `er-mk-module-slot`, `er-module-current-env`, `er-module-old-env`, `er-module-version`. `erlang-load-module` updated: first load creates a slot with `:version 1` and `:old nil`; subsequent loads of the same module name copy `:current` into `:old` and increment `:version` (bump-and-shift, single-old-version retention as per OTP semantics). `er-apply-user-module` now reads via `er-module-current-env` so cross-module calls always hit the latest version. 13 new runtime tests (mostly in `tests/runtime.sx`): slot constructor + accessors, registry-after-first-load (v1, old nil), registry-after-second-load (v2, old = previous current env identity, current = new env), v3 on triple-load, registry-reset clears. Total **543/543** (was 530/530). Note: sx-tree path-based MCP tools (`sx_replace_node`, `sx_read_subtree`) are broken in this worktree's `mcp_tree.exe` (every path returns/replaces form 0); edits applied via a Python script then `sx_validate`d. Pattern-based tools (`sx_find_all`, `sx_rename_symbol`) still work fine.
- **2026-05-14 Phase 7 + Phase 8 scoped** — Plan extended with two new phases driven by fed-sx (see `plans/fed-sx-design.md` §17.5). Phase 7 brings hot code reload back in scope (was previously listed as out-of-scope): module versioning slot, `code:load_file/1`/`purge/1`/`soft_purge/1`/`which/1`/`is_loaded/1`, cross-module calls hitting current, local calls keeping start-time semantics until function returns. Phase 8 introduces a runtime-extensible **FFI BIF registry** that replaces today's hardcoded `er-apply-bif`/`er-apply-remote-bif` cond chains, plus a term-marshalling layer and concrete BIFs for `crypto:hash`, `cid:from_bytes`/`to_string`, `file:read_file`/`write_file`/`list_dir`/`delete`, `httpc:request`, `sqlite:open`/`exec`/`query`. Scope decisions header updated accordingly. Baseline 530/530 unchanged; no code touched this iteration.
- **2026-04-25 BIF round-out — Phase 6 complete, full plan ticked** — Added 18 standard BIFs in `lib/erlang/transpile.sx`. **erlang module:** `abs/1` (negates negative numbers), `min/2`/`max/2` (use `er-lt?` so cross-type comparisons follow Erlang term order), `tuple_to_list/1`/`list_to_tuple/1` (proper conversions), `integer_to_list/1` (returns SX string per the char-list shim), `list_to_integer/1` (uses `parse-number`, raises badarg on failure), `is_function/1` and `is_function/2` (arity-2 form scans the fun's clause patterns). **lists module:** `seq/2`/`seq/3` (right-fold builder with step), `sum/1`, `nth/2` (1-indexed, raises badarg out of range), `last/1`, `member/2`, `append/2` (alias for `++`), `filter/2`, `any/2`, `all/2`, `duplicate/2`. 40 new eval tests with positive + negative cases, plus a few that compose existing BIFs (e.g. `lists:sum(lists:seq(1, 100)) = 5050`). Total suite **530/530** — every checkbox in `plans/erlang-on-sx.md` is now ticked.
- **2026-04-25 ETS-lite green** — Scheduler state gains `:ets` (table-name → mutable list of tuples). New `er-apply-ets-bif` dispatches `ets:new/2` (registers table by atom name; rejects duplicate name with `{badarg, Name}`), `insert/2` (set semantics — replaces existing entry with the same first-element key, else appends), `lookup/2` (returns Erlang list — `[Tuple]` if found else `[]`), `delete/1` (drop table), `delete/2` (drop key; rebuilds entry list), `tab2list/1` (full list view), `info/2` with `size` only. Keys are full Erlang terms compared via `er-equal?`. 13 new eval tests: new return value, insert true, lookup hit + miss, set replace, info size after insert/delete, tab2list length, table delete, lookup-after-delete raises badarg, multi-key aggregate sum, tuple-key insert + lookup, two independent tables. Total suite 490/490.
- **2026-04-25 binary pattern matching green** — Parser additions: `<<...>>` literal/pattern in `er-parse-primary`, segment grammar `Value [: Size] [/ Spec]` (Spec defaults to `integer`, supports `binary` for tail). Critical fix: segment value uses `er-parse-primary` (not `er-parse-expr-prec`) so the trailing `:Size` doesn't get eaten by the postfix `Mod:Fun` remote-call handler. Runtime value: `{:tag "binary" :bytes (list of int 0-255)}`. Construction: integer segments emit big-endian bytes (size in bits, must be multiple of 8); binary-spec segments concatenate. Pattern matching consumes bytes from a cursor at the front, decoding integer segments big-endian, capturing `Rest/binary` tail at the end. Whole-binary length must consume exactly. New BIFs: `is_binary/1`, `byte_size/1`. Binaries participate in `er-equal?` (byte-wise) and format as `<<b1,b2,...>>`. 21 new eval tests: tag/predicate, byte_size for 8/16/32-bit segments, single + multi segment match, three 8-bit, tail rest size + content, badmatch on size mismatch, `=:=` equality, var-driven construction. Total suite 477/477.
@@ -131,4 +249,10 @@ _Newest first._
## Blockers
- _(none yet)_
- **Phase 10a — opcode emission requires `lib/compiler.sx` (out of scope)** (2026-05-15). Architecture fully traced this iteration: the OCaml JIT (`sx_vm.ml` `jit_compile_lambda`, ref-set at line 1206) invokes the SX-level `compile` from **`lib/compiler.sx`** via the CEK machine; that is the sole SX→bytecode producer. Erlang's hot helpers (`er-match-tuple`, `er-bif-*`, …) are SX functions in `transpile.sx` that get JIT-compiled through this path. To emit `erlang.OP_*` they must be recognized as intrinsics inside `compiler.sx`'s `compile-call` (the file's own docstring already anticipates this: "Compilers call `extension-opcode-id` to emit extension opcodes" — designed, not yet implemented). `lib/compiler.sx` is **lib-root**, excluded by the ground rules ("Don't edit lib/ root") and absent from the widened `lib/erlang/** + hosts/ocaml/** (extension only)` scope — editing it changes every guest language's JIT, so it must be owned by a shared-compiler session, not this loop. **Fix path:** that session implements 10a.1 (intrinsic registry in `compiler.sx`) + 10a.2 (`compile-call` emits the opcode when registered & `extension-opcode-id` non-nil, else generic CALL). Erlang's BIF handlers (10b, ids 230-239, all real) light up the instant emission exists — zero further work here. The control opcodes (222-229) additionally need 10a.3 (operand contract) + OCaml↔SX runtime-state bridging (Erlang scheduler/mailbox live in `lib/erlang/runtime.sx`, not OCaml).
- **Phase 9g — Perf bench gated on 9a** (2026-05-14). The conformance half of 9g (709/709 with stub VM loaded) is satisfied; the perf-bench half requires 9a's bytecode compiler to actually emit the new opcodes at hot call sites. Until then a benchmark would measure today's `er-bif-*` / `er-match-*` numbers unchanged (since the stub handlers wrap them 1-to-1). Re-fire 9g after 9a lands.
- **Phase 9a — Opcode extension mechanism** — **RESOLVED 2026-05-15.** User widened scope to include hosts/ (merging back anyhow). Cherry-picked vm-ext phases A-E + force-linked `Sx_vm_extensions` into sx_server.exe. `extension-opcode-id` live; conformance 709/709. Remaining integration work (erlang_ext.ml + wiring the SX stub dispatcher to consult real ids) tracked as ordinary in-scope checkboxes now, not blockers.
- **SX runtime lacks platform primitives for crypto / dir-listing / HTTP / SQLite** (2026-05-14). Probed in `mcp_tree.exe`'s embedded `sx_server.exe`: `(sha256 "x")`, `(blake3 "x")`, `(hash "sha256" "x")`, `(file-list-dir "plans")`, `(http-get "url")`, `(fetch "url")` all return `Undefined symbol`. Only file-byte-level primitives exist: `file-read` ✓, `file-write` ✓, `file-delete` ✓, `file-exists?` ✓. Out-of-scope to add these (they live in `hosts/` per ground rules). Blocked Phase 8 BIFs: `crypto:hash/2`, `cid:from_bytes/1`, `cid:to_string/1`, `file:list_dir/1`, `httpc:request/4`, `sqlite:open/exec/query/close`. **Fix path:** a future iteration on the architecture branch can register host primitives (e.g. expose OCaml's `Digestif` for hashes, `Sys.readdir` for list_dir, `cohttp` for httpc); the BIF wrappers here will then become one-line registrations against `er-bif-registry`.

2638
plans/fed-sx-design.md Normal file

File diff suppressed because it is too large Load Diff

922
plans/fed-sx-milestone-1.md Normal file
View File

@@ -0,0 +1,922 @@
# fed-sx Milestone 1 — Kernel + Registries + Pin Smoke Test
Concrete implementation plan for the smallest fed-sx that proves the architecture
works end-to-end. Reference: `plans/fed-sx-design.md`. Prerequisite: Erlang-on-SX
Phases 7 (hot reload) + 8 (FFI BIFs).
## Goal
Ship a single-instance, single-actor fed-sx server that:
1. Boots from a verified genesis bundle.
2. Accepts and durably appends signed activities via `POST /activity`.
3. Folds them into projections in real time.
4. Serves AP-standard endpoints (actor, outbox, artifacts, capabilities).
5. Demonstrates **two extensibility proof-points** end-to-end with zero kernel
code changes between definition and use:
- **Verb extensibility** (§5 meta-level): publish `DefineActivity{Pin}` +
`DefineProjection{pin-state}`, then publish a `Pin` activity, observe it
validated and projected.
- **Reactive application extensibility** (§§18-19): publish
`DefineSubscription{Topic}` + `Subscribe{topic: smoketest}` +
`DefineTrigger{when: that subscription, then: publish TestEcho}`, then
publish a tagged Note, observe the subscription match, the trigger fire,
and the derived activity appear in the outbox.
Federation, multi-actor, advanced verbs, IPFS, browser UI, operator dashboard
are **explicitly v2**.
## Non-goals (what milestone 1 deliberately does NOT do)
- **Federation.** No `POST /inbox` from peers, no `Follow`, no delivery queue, no
webfinger discovery flow. Single instance only.
- **Multi-actor.** Single domain actor (`acct:next@next.rose-ash.com`).
- **IPFS / S3 storage backends.** Files on disk only.
- **Advanced verbs.** No `Endorse`, `Supersede`, `Test`, `Build`, `Compose`,
`Note`, `Announce`. Only the four bootstrap verbs (`Create`, `Update`, `Delete`)
plus a defined-from-the-log `Pin` for the smoke test. (`Announce` deferred —
no use case until federation exists.)
- **Browser UI.** Curl-shaped API only.
- **Operator dashboard, quarantine UX.** Logs only.
- **Performance work.** Functional correctness first; perf when measured.
- **Cross-host conformance test corpus.** Only the OCaml/Erlang-on-SX host runs
fed-sx in v1; conformance suite for other hosts is v2.
## Architecture summary
```
POST /activity
┌──────────────────────────┐
│ HTTP server (Erlang-on-SX)│
└─────────────┬─────────────┘
┌─────────────▼──────────────┐
│ Validation pipeline driver │
│ (envelope→sig→schema→...) │
└─────────────┬──────────────┘
┌─────────────▼──────────────┐
│ Log append (JSONL segment) │ ← canonical
└─────────────┬──────────────┘
┌─────────────▼──────────────┐
│ Projection workers │ ← gen_server per
│ (fold scheduler) │ projection
└─────────────────────────────┘
Projection state
(queryable via HTTP)
Native primitives (Erlang-on-SX BIFs from Phase 8):
crypto:* cid:* fs:* http:* sqlite:*
Genesis bundle (binary-embedded SX):
activity-types object-types projections
validators codecs sig-suites
```
## Build order
Eight steps in dependency order. Each step has concrete deliverables, testable
in isolation, and a clear acceptance check.
| Step | Title | Depends on |
|------|-------|------------|
| **1** | Repo skeleton + canonical CID computation | Phase 8 (cid BIFs) |
| **2** | Activity envelope + signature verify | Phase 8 (crypto BIFs) |
| **3** | JSONL log + sequence numbers | Phase 8 (fs BIFs) |
| **4** | Genesis bundle (SX sources + bundling + CID verification) | Step 1 |
| **5** | Registry mechanism + bootstrap-projection dispatch | Steps 2, 4 |
| **6** | Validation pipeline driver + `POST /activity` | Steps 2, 3, 5 |
| **7** | Projection scheduler (gen_server per projection) | Steps 5, 6 |
| **8** | HTTP server, AP endpoints, projection queries | Steps 6, 7 |
| **9** | Smoke tests (Pin verb + reactive application) | Steps 1-8 |
---
## Step 1 — Repo skeleton + canonical CID
**Deliverables:**
```
next/
├── README.md # what this is
├── kernel/ # Erlang-on-SX
│ └── (empty for now)
├── genesis/ # core SX bootstrap definitions
│ └── (empty for now)
├── tests/ # smoke test scripts
│ └── (empty for now)
└── data/ # gitignored runtime state
├── log/
├── objects/
├── snapshots/
├── indexes/
└── keys/
```
Plus one Erlang-on-SX module:
```erlang
% next/kernel/cid.erl
-module(cid).
-export([from_sx/1, to_string/1, from_string/1, equals/2]).
from_sx(SxValue) ->
Cbor = cid:cbor_encode(canonicalize_sx(SxValue)),
Hash = crypto:sha2_256(Cbor),
cid:from_bytes(<<"raw">>, Hash). % defaults to dag-cbor codec
canonicalize_sx(V) -> ... % sorts dict keys, normalizes strings
```
**Tests:**
- Same SX value → same CID across multiple invocations.
- Different SX values → different CIDs.
- Whitespace/comment differences in source → identical CIDs (parsed AST identical).
- Reordered dict keys → identical CIDs (sorted-key canonicalization).
- Cross-host parity (just OCaml host for v1, but write the test so adding hosts is mechanical).
**Acceptance:** `bash next/tests/cid.sh` passes 10+ cases.
---
## Step 2 — Activity envelope + signature verify
**Deliverables:**
```erlang
% next/kernel/envelope.erl
-module(envelope).
-export([validate_shape/1, canonical_bytes/1, verify_signature/2]).
% Envelope shape per design §3.1:
% #{id, type, actor, published, to, cc, audience_extras,
% object | target | origin | result,
% capabilities_required, proofs, signature}
validate_shape(Activity) -> ok | {error, Reason}.
canonical_bytes(Activity) ->
% Strip signature, canonicalize via dag-cbor, return bytes for sig coverage
Stripped = maps:remove(signature, Activity),
cid:cbor_encode(canonicalize_for_sig(Stripped)).
verify_signature(Activity, ActorState) ->
% Time-aware: find key with id == sig.key_id that was active at published
% Per design §9.6
...
```
**Tests:**
- Envelope shape: required fields present (id, type, actor, published, signature)
- Envelope shape: type is a known activity-type or unknown-but-string
- Envelope shape: signature has key_id, algorithm, value
- Sig verify: valid RSA-SHA256 signature against published key → ok
- Sig verify: valid Ed25519 signature → ok
- Sig verify: tampered envelope → fail
- Sig verify: key superseded before activity timestamp → fail
- Sig verify: key superseded after activity timestamp → ok (historical valid)
**Acceptance:** `bash next/tests/envelope.sh` passes 15+ cases.
---
## Step 3 — JSONL log + sequence numbers
**Deliverables:**
```erlang
% next/kernel/log.erl
-module(log).
-export([open/1, append/2, read_segment/2, tip/1, replay/3]).
% Per design §15.2: per-actor outbox, segments cap ~64MB,
% format = JSONL (one canonical JSON-LD activity per line)
open(ActorId) ->
BasePath = log_path_for_actor(ActorId),
fs:mkdir_p(BasePath),
{ok, #{base => BasePath, current => current_segment(BasePath), seq => next_seq(BasePath)}}.
append(LogState, Activity) ->
Json = jsonld:encode(Activity),
Path = current_segment_path(LogState),
Line = <<Json/binary, "\n">>,
fs:append_file(Path, Line),
NewSeq = LogState#{seq := LogState.seq + 1},
rotate_if_needed(NewSeq).
% replay/3 calls Fun(Activity, Acc) for every activity in chronological order
replay(LogState, InitAcc, Fun) -> ...
```
**Tests:**
- Append + read back gives identical activity (round-trip).
- Sequence numbers monotonic and gap-free per actor.
- Segment rotation at size threshold.
- Replay visits all activities in append order across multiple segments.
- Restart preserves tip pointer (seq number resumes correctly).
- Concurrent appends (using gen_server-mediated access) are serialized correctly.
**Acceptance:** `bash next/tests/log.sh` passes 10+ cases.
---
## Step 4 — Genesis bundle
**Deliverables:**
Genesis bundle SX sources (per design §12.2). Each is a small SX file authored
by hand for the bootstrap set:
```
next/genesis/
├── manifest.sx # bundle root: lists all definitions
├── activity-types/
│ ├── create.sx # DefineActivity{name: "Create", ...}
│ ├── update.sx
│ └── delete.sx
├── object-types/
│ ├── sx-artifact.sx
│ ├── note.sx
│ ├── tombstone.sx
│ ├── define-activity.sx # DefineObject for the Define* meta types
│ ├── define-object.sx
│ ├── define-projection.sx
│ ├── define-validator.sx
│ ├── define-codec.sx
│ ├── define-sig-suite.sx
│ └── snapshot.sx
├── projections/
│ ├── activity-log.sx # identity projection
│ ├── by-type.sx
│ ├── by-actor.sx
│ ├── by-object.sx
│ ├── actor-state.sx
│ ├── define-registry.sx # the chicken-and-egg projection
│ └── audience-graph.sx
├── validators/
│ ├── envelope-shape.sx
│ ├── signature.sx
│ └── type-schema.sx
├── codecs/
│ ├── dag-cbor.sx # delegates to cid:cbor_encode/decode BIFs
│ ├── raw.sx
│ └── dag-json.sx
├── sig-suites/
│ ├── rsa-sha256-2018.sx
│ └── ed25519-2020.sx
└── audience/
├── public.sx
├── followers.sx
└── direct.sx
```
Plus a build-time bundler:
```erlang
% next/kernel/bootstrap.erl
-module(bootstrap).
-export([build_genesis/1, verify_genesis/1, load_genesis/1]).
build_genesis(SourceDir) ->
% Walk SourceDir, parse each .sx file, build a single dag-cbor bundle,
% compute its CID, write bundle.cbor + CID to data/genesis/
...
verify_genesis(BundlePath) ->
% Compute CID of the bundle as loaded; compare to expected (hardcoded
% in the kernel binary). Mismatch → halt.
...
load_genesis(BundlePath) ->
% Parse the bundle, register all definitions in the in-memory registry
...
```
**Tests:**
- All genesis SX files parse cleanly.
- Bundle CID is deterministic (rebuild same sources → same CID).
- Bundle reload reproduces the exact same registry state.
- Tampered bundle → `verify_genesis` returns `{error, cid_mismatch}`.
**Acceptance:** `bash next/tests/bootstrap.sh` passes; `next/data/genesis/bundle.cbor`
created with a known stable CID.
---
## Step 5 — Registry mechanism + bootstrap dispatch
**Deliverables:**
Registries are gen_servers, one per kind, each holding the active version map:
```erlang
% next/kernel/registry.erl
-module(registry).
-behaviour(gen_server).
-export([start_link/0, lookup/2, register/3, list/1]).
% Internal state:
% #{activity_types => #{Name => #{cid, schema_fn, semantics_fn, supersedes}},
% object_types => ...,
% projections => ...,
% validators => ...,
% codecs => ...,
% sig_suites => ...,
% ...}
lookup(Kind, Name) -> {ok, Entry} | {error, not_found}.
register(Kind, Name, Entry) -> ok | {error, Reason}.
list(Kind) -> [#{name, cid}].
```
The `define-registry` projection's fold updates this gen_server's state when
new `Define*` activities arrive. (Bootstrapping circle resolved: at startup,
`bootstrap:load_genesis/1` populates the registry directly; from then on, the
projection fold maintains it.)
**Tests:**
- After genesis load, `registry:list(activity_types)` returns Create/Update/Delete.
- `registry:lookup(activity_types, "Create")` returns the schema and semantics.
- A new `DefineActivity{name: "Pin"}` activity (synthesised, hand-signed for the
test) routes through the projection fold, ends up in the registry.
- Lookup never caches across activities (verified by introducing a new definition
mid-test and confirming the next lookup sees it).
**Acceptance:** `bash next/tests/registry.sh` passes 10+ cases.
---
## Step 6 — Validation pipeline + POST /activity
**Deliverables:**
```erlang
% next/kernel/pipeline.erl
-module(pipeline).
-export([validate_inbound/1, validate_outbound/1]).
% Per design §14, run stages in order, halt on first failure.
validate_inbound(Activity) ->
Stages = [
fun stage_envelope/1,
fun stage_signature/1,
fun stage_replay/1,
fun stage_audience/1,
fun stage_activity_schema/1,
fun stage_object_schema/1,
fun stage_content_validators/1,
fun stage_capabilities/1,
fun stage_trust/1
],
run_stages(Activity, Stages).
validate_outbound(Activity) ->
% Subset of inbound stages (no replay, no trust check; auth done at HTTP layer)
...
```
```erlang
% next/kernel/outbox.erl
-module(outbox).
-export([publish/2]).
publish(ActorId, ActivityRequest) ->
Activity = construct_envelope(ActorId, ActivityRequest),
Signed = sig:sign(Activity, ActorId),
case pipeline:validate_outbound(Signed) of
ok ->
log:append(actor_log(ActorId), Signed),
projection:async_fold(Signed),
{ok, #{cid => cid:from_sx(Signed),
ap_id => maps:get(id, Signed)}};
{error, Reason} ->
{error, Reason}
end.
```
**Tests:**
- Valid activity through full pipeline → appended to log.
- Bad envelope → 400, not in log.
- Bad signature → 401, not in log.
- Replayed activity → 200 duplicate, not re-appended.
- Schema violation (e.g. Create with no object) → 422.
- Activity logged before projection completes (async).
**Acceptance:** `bash next/tests/pipeline.sh` passes 15+ cases.
---
## Step 7 — Projection scheduler
**Deliverables:**
```erlang
% next/kernel/projection.erl
-module(projection).
-export([start_link/1, async_fold/1, query/2, snapshot/1]).
-behaviour(gen_server).
% One gen_server per active projection. State:
% #{cid, name, fold_fn, current_state, log_tip,
% snapshot_dir, last_snapshot_at}
% async_fold/1 broadcasts a new activity to every projection gen_server;
% each folds it into its own state. Failures (gas, sandbox violation)
% tag the activity but don't affect log durability.
% query/2 returns current state (or state-as-of)
% snapshot/1 forces a snapshot now (also runs periodically)
```
```erlang
% next/kernel/sandbox.erl
-module(sandbox).
-export([eval_pure/2, eval_crypto/2, eval_effectful/3]).
% eval_pure runs an SX function in pure mode: no IO platform, gas budget,
% deterministic. Used by projection folds, validators, audience predicates.
% Wrapper over the SX runtime evaluator with a stripped platform.
```
**Tests:**
- New activity → all projections fold it concurrently.
- Projection fold completes within gas budget.
- Gas-exhausting fold → activity tagged, projection state unchanged, no kernel crash.
- Sandbox violation (fold tries IO) → same handling.
- Snapshot create + reload → state matches.
- Snapshot CID stable across kernel restarts.
**Acceptance:** `bash next/tests/projection.sh` passes 15+ cases.
---
## Step 8 — HTTP server + endpoints
**Deliverables:**
Core endpoints (per design §16.1):
```
GET /actors/<id> # actor doc
GET /actors/<id>/outbox # OrderedCollection
GET /actors/<id>/outbox?page=true # OrderedCollectionPage
POST /activity # publish (auth: bearer token)
GET /artifacts/<cid> # CID-addressed artifact
GET /artifacts/<cid>/raw
GET /projections # list of projections
GET /projections/<name> # full state
GET /projections/<name>?at=<ts> # time-travel
GET /projections/<name>/<key> # indexed lookup
GET /define-registry
GET /.well-known/sx-capabilities
GET /.well-known/webfinger
```
```erlang
% next/kernel/http_server.erl
-module(http_server).
-export([start/1, route/1]).
start(Port) ->
http:listen(Port, fun ?MODULE:route/1).
route(Request) -> {Status, Headers, Body}.
```
Content negotiation per `Accept`:
- `application/activity+json` (default)
- `application/cbor` (dag-cbor)
- `application/json` (compact, no @context expansion)
- `application/sx`
Auth on `POST /activity`: bearer token from env var `NEXT_PUBLISH_TOKEN`.
**Tests:**
- Each endpoint returns expected shape for known artifact.
- Content negotiation: same artifact in 4 representations.
- 404 for unknown artifact CID.
- 401 for `POST /activity` without token.
- Pagination: outbox with > 50 activities returns OrderedCollectionPage.
**Acceptance:** `bash next/tests/http.sh` passes 20+ cases.
---
## Step 9 — Smoke tests
**The proof points.** Two end-to-end smoke tests demonstrate, between them, that
fed-sx is genuinely a substrate for distributed reactive applications expressed
as data — not a system you extend by writing kernel code.
- **9a — Pin smoke test (`next/tests/smoke_pin.sh`)** — verb extensibility:
defining a new activity type and projection at runtime via `Define*`
artifacts. Verifies the meta-level (§5).
- **9b — Reactive application smoke test (`next/tests/smoke_app.sh`)** —
application extensibility: defining a new subscription type, subscribing,
registering a trigger, and observing the full reactive loop fire end-to-end
without kernel code changes. Verifies §§18-19.
Both must pass for milestone 1 acceptance.
### Step 9a — Pin smoke test
**Test script:** `next/tests/smoke_pin.sh`
```bash
#!/usr/bin/env bash
set -euo pipefail
# 0. Start a fresh fed-sx kernel (background)
./next/scripts/start.sh fresh
sleep 2
TOKEN=$(cat next/data/keys/publish.token)
# 1. Verify actor exists
curl -s http://localhost:9999/actors/next | jq -e '.type == "Person"'
# 2. Verify outbox has actor's first Create{Person}
curl -s http://localhost:9999/actors/next/outbox?page=true \
| jq -e '.orderedItems | length == 1 and .[0].type == "Create"'
# 3. Verify Pin is NOT a known activity type
curl -s http://localhost:9999/define-registry?kind=activity_types \
| jq -e '.[] | select(.name == "Pin") | length == 0' || exit 1
# 4. Publish DefineActivity{name: "Pin", schema: ..., semantics: ...}
PIN_DEF=$(cat <<'JSON'
{
"type": "Create",
"object": {
"type": "DefineActivity",
"name": "Pin",
"schema": "(fn (act) (and (string? (-> act :object :path)) (cid? (-> act :object :cid))))",
"semantics": "(fn (state act) (assoc-in state [:pins (-> act :object :path)] (-> act :object :cid)))"
}
}
JSON
)
curl -s -X POST http://localhost:9999/activity \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/activity+json" \
-d "$PIN_DEF" | jq -e '.cid' > /dev/null
# 5. Verify Pin IS now a known activity type
curl -s http://localhost:9999/define-registry?kind=activity_types \
| jq -e '.[] | select(.name == "Pin") | length == 1'
# 6. Also publish a DefineProjection{name: "pin-state"} that folds Pin into state
PIN_PROJ=$(cat <<'JSON'
{
"type": "Create",
"object": {
"type": "DefineProjection",
"name": "pin-state",
"initial-state": "{}",
"fold": "(fn (state act) (if (= (:type act) \"Pin\") (assoc state (-> act :object :path) (-> act :object :cid)) state))"
}
}
JSON
)
curl -s -X POST http://localhost:9999/activity \
-H "Authorization: Bearer $TOKEN" \
-d "$PIN_PROJ" | jq -e '.cid'
# 7. Now publish a Pin activity
PIN=$(cat <<'JSON'
{
"type": "Pin",
"object": {
"type": "PinSpec",
"path": "/docs/intro",
"cid": "bafyreigh2akiscaildc3xqxx4xqxx4xqxx4xqxx4xqxx4xqxx4xqxx4xqxxe"
}
}
JSON
)
curl -s -X POST http://localhost:9999/activity \
-H "Authorization: Bearer $TOKEN" \
-d "$PIN" | jq -e '.cid'
# 8. Verify Pin appears in outbox
curl -s http://localhost:9999/actors/next/outbox?page=true \
| jq -e '.orderedItems | map(select(.type == "Pin")) | length == 1'
# 9. Verify pin-state projection has the entry
sleep 1 # allow async projection
curl -s http://localhost:9999/projections/pin-state \
| jq -e '."/docs/intro" == "bafyreigh2akiscaildc3xqxx4xqxx4xqxx4xqxx4xqxx4xqxx4xqxx4xqxxe"'
# 10. Negative test: publish a malformed Pin (missing path) → expect 422
BAD_PIN='{"type": "Pin", "object": {"cid": "bafy..."}}'
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:9999/activity \
-H "Authorization: Bearer $TOKEN" -d "$BAD_PIN")
[[ "$HTTP_STATUS" == "422" ]] || { echo "expected 422, got $HTTP_STATUS"; exit 1; }
# 11. Restart kernel; verify state recovers
./next/scripts/stop.sh
./next/scripts/start.sh
sleep 2
curl -s http://localhost:9999/projections/pin-state \
| jq -e '."/docs/intro" == "bafyreigh2akiscaildc3xqxx4xqxx4xqxx4xqxx4xqxx4xqxx4xqxxe"'
echo "✓ Pin smoke test passed — verb extensibility demonstrated end-to-end"
```
**Acceptance for 9a:** smoke test exits 0. The whole flow happens with **zero
fed-sx kernel code changes** between defining the verb and using it.
### Step 9b — Reactive application smoke test
**The bigger proof point.** Demonstrates that fed-sx supports distributed
reactive applications composed of `DefineSubscription` + `DefineTrigger` +
`DefineProjection` — the application model from §§18-19.
The test runs on a single instance (federation is v2), so the "subscriber" and
"publisher" are the same actor. That's intentional — milestone 1 proves the
mechanism; milestone 2 spreads it across instances.
**Test script:** `next/tests/smoke_app.sh`
```bash
#!/usr/bin/env bash
set -euo pipefail
# Assumes 9a has already run (fresh kernel optional; can run alongside).
TOKEN=$(cat next/data/keys/publish.token)
BASE=http://localhost:9999
# 1. Verify "Topic" subscription type and "Subscribe" verb are NOT yet defined.
curl -s "$BASE/define-registry?kind=subscription_types" \
| jq -e 'map(select(.name == "Topic")) | length == 0'
# 2. Publish DefineSubscription{name: "Topic", ...}
TOPIC_DEF=$(cat <<'JSON'
{
"type": "Create",
"object": {
"type": "DefineSubscription",
"name": "Topic",
"schema": "(fn (sub) (string? (-> sub :tag)))",
"match": "(fn (sub act) (and (= (:type act) \"Note\") (member? (-> sub :tag) (or (-> act :object :tags) (list)))))",
"delivery": "{:default :push :modes (list :push :pull)}"
}
}
JSON
)
curl -s -X POST "$BASE/activity" \
-H "Authorization: Bearer $TOKEN" -d "$TOPIC_DEF" | jq -e '.cid'
# 3. Verify Topic IS now a known subscription type.
curl -s "$BASE/define-registry?kind=subscription_types" \
| jq -e 'map(select(.name == "Topic")) | length == 1'
# 4. Subscribe to the "smoketest" topic.
SUBSCRIBE=$(cat <<'JSON'
{
"type": "Subscribe",
"object": {"type": "Topic", "tag": "smoketest"}
}
JSON
)
SUB_CID=$(curl -s -X POST "$BASE/activity" \
-H "Authorization: Bearer $TOKEN" -d "$SUBSCRIBE" | jq -r '.cid')
# 5. Verify subscriptions projection has the new entry.
sleep 1
curl -s "$BASE/projections/subscriptions" \
| jq -e '.["https://next.rose-ash.com/actors/next"] | map(select(.type == "Topic")) | length == 1'
# 6. Define a projection that records matched activities (per-application
# namespace would happen via DefineApplication in v1.x; for v1 the
# projection is global to the actor).
TOPIC_PROJ=$(cat <<'JSON'
{
"type": "Create",
"object": {
"type": "DefineProjection",
"name": "topic-events",
"initial-state": "{}",
"fold": "(fn (state act) (if (and (= (:type act) \"Note\") (member? \"smoketest\" (or (-> act :object :tags) (list)))) (assoc-in state [(:cid act)] act) state))"
}
}
JSON
)
curl -s -X POST "$BASE/activity" \
-H "Authorization: Bearer $TOKEN" -d "$TOPIC_PROJ" | jq -e '.cid'
# 7. Define a trigger: when a Topic{smoketest} subscription matches, publish
# a TestEcho activity. We need an "Echo" activity type first.
ECHO_DEF=$(cat <<'JSON'
{
"type": "Create",
"object": {
"type": "DefineActivity",
"name": "TestEcho",
"schema": "(fn (act) (cid? (-> act :object :echoes)))",
"semantics": "(fn (state act) state)"
}
}
JSON
)
curl -s -X POST "$BASE/activity" \
-H "Authorization: Bearer $TOKEN" -d "$ECHO_DEF" | jq -e '.cid'
TRIGGER=$(cat <<JSON
{
"type": "Create",
"object": {
"type": "DefineTrigger",
"name": "echo-on-smoketest",
"when-subscription": "$SUB_CID",
"cascade-limit": 1,
"then": "(fn (act sub env) {:publish (list {:type \"TestEcho\" :object {:echoes (:cid act)}})})"
}
}
JSON
)
curl -s -X POST "$BASE/activity" \
-H "Authorization: Bearer $TOKEN" -d "$TRIGGER" | jq -e '.cid'
# 8. Capture outbox length so we can detect new entries.
BEFORE=$(curl -s "$BASE/actors/next/outbox?page=true" \
| jq -r '.orderedItems | length')
# 9. Publish a Note tagged "smoketest" — should match subscription, fire trigger,
# cause TestEcho to be published.
NOTE=$(cat <<'JSON'
{
"type": "Create",
"object": {
"type": "Note",
"content": "hello reactive world",
"tags": ["smoketest"]
}
}
JSON
)
NOTE_CID=$(curl -s -X POST "$BASE/activity" \
-H "Authorization: Bearer $TOKEN" -d "$NOTE" | jq -r '.cid')
# 10. Wait for projection + trigger.
sleep 2
# 11. Verify topic-events projection captured the Note.
curl -s "$BASE/projections/topic-events" \
| jq -e ". | to_entries | length == 1"
# 12. Verify outbox grew by exactly TWO activities (the Note + the trigger's TestEcho).
AFTER=$(curl -s "$BASE/actors/next/outbox?page=true" \
| jq -r '.orderedItems | length')
[[ $((AFTER - BEFORE)) == 2 ]] || { echo "expected +2 activities, got $((AFTER - BEFORE))"; exit 1; }
# 13. Verify the latest activity is a TestEcho referencing the original Note's CID.
curl -s "$BASE/actors/next/outbox?page=true" \
| jq -e ".orderedItems[0] | .type == \"TestEcho\" and .object.echoes == \"$NOTE_CID\""
# 14. Negative case: publish a Note WITHOUT the "smoketest" tag — must NOT
# trigger, must NOT echo.
BEFORE2=$(curl -s "$BASE/actors/next/outbox?page=true" | jq -r '.orderedItems | length')
NOTE_OTHER=$(cat <<'JSON'
{"type": "Create", "object": {"type": "Note", "content": "no match", "tags": ["other"]}}
JSON
)
curl -s -X POST "$BASE/activity" \
-H "Authorization: Bearer $TOKEN" -d "$NOTE_OTHER" | jq -e '.cid'
sleep 2
AFTER2=$(curl -s "$BASE/actors/next/outbox?page=true" | jq -r '.orderedItems | length')
[[ $((AFTER2 - BEFORE2)) == 1 ]] || { echo "expected +1 activity (no echo), got $((AFTER2 - BEFORE2))"; exit 1; }
# 15. Cascade limit check: prove the trigger doesn't recursively echo TestEcho.
# The TestEcho activity itself should NOT match the Topic{smoketest}
# subscription (it's not a Note), so no cascade, but verify cascade-depth
# was set to 1 on the echo so a future trigger on TestEcho would refuse.
LATEST_ECHO=$(curl -s "$BASE/actors/next/outbox?page=true" \
| jq -r '.orderedItems | map(select(.type == "TestEcho")) | .[0]')
echo "$LATEST_ECHO" | jq -e '."cascade-depth" == 1'
# 16. Restart kernel; verify subscription, trigger, projection all survive.
./next/scripts/stop.sh
./next/scripts/start.sh
sleep 2
curl -s "$BASE/projections/subscriptions" \
| jq -e '.["https://next.rose-ash.com/actors/next"] | map(select(.type == "Topic")) | length == 1'
curl -s "$BASE/projections/topic-events" | jq -e ". | to_entries | length >= 1"
curl -s "$BASE/define-registry?kind=triggers" \
| jq -e 'map(select(.name == "echo-on-smoketest")) | length == 1'
echo "✓ Reactive application smoke test passed — Subscribe + Trigger + Projection demonstrated end-to-end"
```
**What this proves (and what it doesn't):**
Proves:
- `DefineSubscription` + `Subscribe` mechanism works end-to-end.
- Subscription's `match-fn` evaluates correctly in pure mode against inbound
activities.
- `DefineTrigger` fires on subscription matches.
- Trigger's `then-sx` can publish derived activities (the `:publish` result).
- Cascade-depth metadata propagates correctly.
- Subscription state, trigger registration, and projection state all survive
kernel restart (snapshot + log replay).
- The full reactive application loop works without any kernel code changes
between defining the components and exercising them.
Does NOT prove (deferred to milestone 2+):
- Cross-instance subscriptions (federation).
- Trigger `:effect` results calling effectful primitives.
- `DefineApplication` bundle install/update/fork.
- Per-application namespace isolation.
- Cascade prevention against malicious cascading from peer instances.
**Acceptance for 9b:** smoke test exits 0. Like 9a, **zero fed-sx kernel code
changes** between defining the application components and observing them
operate.
---
## Acceptance criteria for milestone 1
All of:
1. **Each step's test suite passes** (`bash next/tests/<step>.sh`).
2. **Both smoke tests pass** (`bash next/tests/smoke_pin.sh` and
`bash next/tests/smoke_app.sh`).
3. **Erlang-on-SX baseline preserved** — adding fed-sx kernel modules in
`next/kernel/*.erl` doesn't break Phase 1-8 conformance.
4. **Restart durability** — kill the kernel mid-write, restart, projections
resume from snapshot, no log corruption.
5. **Manual Mastodon poke** — point a Mastodon account at
`https://next.rose-ash.com/actors/next` and verify the actor doc fetches and
webfinger discovery works (read-only AP interop, no follow).
## What lands when
This is the work-order an agent (or human) follows. Steps 1-3 can be done in
parallel after the Erlang Phase 8 BIFs land. Steps 4-7 are sequential. Step 8
can start in parallel with step 7. Step 9 is the integration test.
```
Phase 7+8 (loops/erlang) ───┐
┌─── Step 1 ──┬─── Step 2 ──┬─── Step 3
│ │ │
└─────────────┼─── Step 4 ──┴────┐
│ │
└─── Step 5 ───────┤
Step 6 ─────┤
Step 7 ─────┤
Step 8 ─────┤
Step 9 ─────┘
```
Estimated effort if done by a focused agent loop, one feature per iteration:
~30-50 commits across all 9 steps. Could plausibly be a `loops/fed-sx` workstream
once Phase 7+8 are done.
## What's deferred to milestone 2
- **Federation** (the second-biggest piece). `POST /inbox`, Follow lifecycle,
delivery queue, backfill, capability negotiation between peers. Whole of
design §13.
- **Multi-actor** with per-user OAuth and capability tokens. Design §9.5.
- **IPFS storage backend** as a `DefineStorage` entry. Design §15.3.
- **Browser client + operator dashboard** (probably in Elm-on-SX or similar).
- **Rich verbs**: `Endorse`, `Supersede`, `Test`, `Build`, `Compose`, `Note`,
`Announce`. All defined as `DefineActivity` artifacts, federated.
- **Cross-host conformance** — Python/JS/Haskell hosts running fed-sx. Design
§11.8.
- **OpenTimestamps proofs** as a `DefineProof` entry.
- **Performance work** — JIT-compiled folds, snapshot acceleration, federation
batching.
Milestone 2 unlocks "real federation between two fed-sx instances." Milestone 3
is the rose-ash port (blog, market, events, federation, account, orders) as
fed-sx applications.
---
## Appendix A: open questions for milestone 1
A few things still under-specified; resolve as work begins.
1. **HTTP server library.** Does the Phase 8 `http:listen/2` BIF wrap an
existing OCaml HTTP server (the sx.rose-ash.com one) or something simpler?
Implementation choice deferred to Phase 8.
2. **JSON-LD library.** AP wire format requires JSON-LD canonicalization for
signature coverage. Either pull a library or write a minimal subset for the
shapes we actually use. Probably the latter — our envelope is well-defined.
3. **Bearer token rotation.** v1 uses a single env-var token. Token rotation
without restart needs registry-style mgmt; can wait.
4. **Snapshot rate limits.** Default in design is "every 1000 activities or
60 seconds." Tunable per-projection later; v1 uses the default.
5. **Genesis bundle format.** Dag-cbor map per §12.2; concrete schema needs
one round of refinement once we author the actual definitions in step 4.