erlang: Phase 8 BIF registry foundation (+18 runtime tests, 600/600)

This commit is contained in:
2026-05-14 19:34:30 +00:00
parent 925bbd0d42
commit 498d2533d8
5 changed files with 88 additions and 6 deletions

View File

@@ -861,6 +861,39 @@
(define er-module-old-env (fn (slot) (get slot :old))) (define er-module-old-env (fn (slot) (get slot :old)))
(define er-module-version (fn (slot) (get slot :version))) (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))))
;; Load an Erlang module declaration. Source must start with ;; Load an Erlang module declaration. Source must start with
;; `-module(Name).` and contain function definitions. Functions ;; `-module(Name).` and contain function definitions. Functions

View File

@@ -1,12 +1,12 @@
{ {
"language": "erlang", "language": "erlang",
"total_pass": 582, "total_pass": 600,
"total": 582, "total": 600,
"suites": [ "suites": [
{"name":"tokenize","pass":62,"total":62,"status":"ok"}, {"name":"tokenize","pass":62,"total":62,"status":"ok"},
{"name":"parse","pass":52,"total":52,"status":"ok"}, {"name":"parse","pass":52,"total":52,"status":"ok"},
{"name":"eval","pass":385,"total":385,"status":"ok"}, {"name":"eval","pass":385,"total":385,"status":"ok"},
{"name":"runtime","pass":52,"total":52,"status":"ok"}, {"name":"runtime","pass":70,"total":70,"status":"ok"},
{"name":"ring","pass":4,"total":4,"status":"ok"}, {"name":"ring","pass":4,"total":4,"status":"ok"},
{"name":"ping-pong","pass":4,"total":4,"status":"ok"}, {"name":"ping-pong","pass":4,"total":4,"status":"ok"},
{"name":"bank","pass":8,"total":8,"status":"ok"}, {"name":"bank","pass":8,"total":8,"status":"ok"},

View File

@@ -1,13 +1,13 @@
# Erlang-on-SX Scoreboard # Erlang-on-SX Scoreboard
**Total: 582 / 582 tests passing** **Total: 600 / 600 tests passing**
| | Suite | Pass | Total | | | Suite | Pass | Total |
|---|---|---|---| |---|---|---|---|
| ✅ | tokenize | 62 | 62 | | ✅ | tokenize | 62 | 62 |
| ✅ | parse | 52 | 52 | | ✅ | parse | 52 | 52 |
| ✅ | eval | 385 | 385 | | ✅ | eval | 385 | 385 |
| ✅ | runtime | 52 | 52 | | ✅ | runtime | 70 | 70 |
| ✅ | ring | 4 | 4 | | ✅ | ring | 4 | 4 |
| ✅ | ping-pong | 4 | 4 | | ✅ | ping-pong | 4 | 4 |
| ✅ | bank | 8 | 8 | | ✅ | bank | 8 | 8 |

View File

@@ -165,6 +165,53 @@
(er-rt-test "registry-reset clears" (dict-has? (er-modules-get) "hr1") false) (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)
(define (define
er-rt-test-summary er-rt-test-summary
(str "runtime " er-rt-test-pass "/" er-rt-test-count)) (str "runtime " er-rt-test-pass "/" er-rt-test-count))

View File

@@ -112,7 +112,7 @@ Driven by **fed-sx** (see `plans/fed-sx-design.md` §17.5): federated modules mu
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. 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.
- [ ] BIF registry: `er-bif-registry` global dict keyed by `"Module/Name/Arity"`, with `er-register-bif!`/`er-lookup-bif`/`er-list-bifs` helpers - [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.
- [ ] 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 must stay 530/530 after migration - [ ] 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 must stay 530/530 after migration
- [ ] Term-marshalling helpers: `er-of-sx` and `er-to-sx` (atom ↔ symbol, tuple ↔ list-of-elements, Erlang list ↔ SX list, binary ↔ string, dict ↔ map). Round-trip tests - [ ] Term-marshalling helpers: `er-of-sx` and `er-to-sx` (atom ↔ symbol, tuple ↔ list-of-elements, Erlang list ↔ SX list, binary ↔ string, dict ↔ map). Round-trip tests
- [ ] `crypto:hash/2``sha256`, `sha512`, `blake3`; takes a binary, returns a binary. Uses the SX-host hash primitive - [ ] `crypto:hash/2``sha256`, `sha512`, `blake3`; takes a binary, returns a binary. Uses the SX-host hash primitive
@@ -126,6 +126,8 @@ Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in
_Newest first._ _Newest first._
- **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 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 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).