Add CEK reactive tests (9/9), fix test runners for CEK-default mode

test-cek-reactive.sx: 9 tests across 4 suites — deref pass-through,
signal without reactive-reset, reactive-reset shift with continuation
capture, scope disposal cleanup. run_cek_reactive_tests.py: new runner
loading signals+frames+cek. Both test runners override sx_ref.eval_expr
back to tree-walk so interpreted .sx uses tree-walk internally.
Plan page added to sx-docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 01:13:31 +00:00
parent 5c4a8c8cc2
commit 893c767238
6 changed files with 728 additions and 2 deletions

View File

@@ -226,7 +226,9 @@
(dict :label "Scoped Effects" :href "/sx/(etc.(plan.scoped-effects))"
:summary "Algebraic effects as the unified foundation — spreads, islands, lakes, signals, and context are all instances of one primitive: a named scope with downward value, upward accumulation, and a propagation mode.")
(dict :label "Foundations" :href "/sx/(etc.(plan.foundations))"
:summary "The computational floor — from scoped effects through algebraic effects and delimited continuations to the CEK machine. Why three registers are irreducible, and the three-axis model (depth, topology, linearity).")))
:summary "The computational floor — from scoped effects through algebraic effects and delimited continuations to the CEK machine. Why three registers are irreducible, and the three-axis model (depth, topology, linearity).")
(dict :label "Deref as Shift" :href "/sx/(etc.(plan.cek-reactive))"
:summary "Phase B: replace explicit effect wrapping with implicit continuation capture. Deref inside reactive-reset performs shift, capturing the rest of the expression as the subscriber.")))
(define reactive-islands-nav-items (list
(dict :label "Overview" :href "/sx/(geography.(reactive))"

302
sx/sx/plans/cek-reactive.sx Normal file
View File

@@ -0,0 +1,302 @@
;; Deref as Shift — CEK-Based Reactive DOM Renderer
;; Phase B: replace explicit effects with implicit continuation capture.
(defcomp ~plans/cek-reactive/plan-cek-reactive-content ()
(~docs/page :title "Deref as Shift — CEK-Based Reactive DOM Renderer"
(p :class "text-stone-500 text-sm italic mb-8"
"Phase A collapsed signals to plain dicts with zero platform primitives. "
"Phase B replaces explicit effect wrapping in the reactive DOM renderer "
"with implicit continuation capture: when " (code "deref") " encounters a signal "
"inside a " (code "reactive-reset") " boundary, it performs " (code "shift") ", "
"capturing the rest of the expression as a continuation. "
"That continuation IS the subscriber.")
;; =====================================================================
;; The Insight
;; =====================================================================
(~docs/section :title "The Insight" :id "insight"
(p "Each reactive binding is a micro-computation:")
(ul :class "list-disc pl-6 mb-4 space-y-1"
(li (code "reactive-text") ": given signal value, set text node content to " (code "(str value)"))
(li (code "reactive-attr") ": given signal value, set attribute to " (code "(str value)")))
(p "Currently wrapped in explicit " (code "effect") " calls. With deref-as-shift, "
"the continuation captures this automatically:")
(~docs/code :code (highlight
";; User writes:\n(div :class (str \"count-\" (deref counter))\n (str \"Value: \" (deref counter)))\n\n;; Renderer internally wraps each expression:\n(div :class (reactive-reset update-attr-fn (str \"count-\" (deref counter)))\n (reactive-reset update-text-fn (str \"Value: \" (deref counter))))\n\n;; When (deref counter) hits a signal inside reactive-reset:\n;; 1. Shift: capture continuation (str \"count-\" [HOLE])\n;; 2. Register continuation as signal subscriber\n;; 3. Return current value for initial render\n;; When counter changes:\n;; Re-invoke continuation with new value → update-fn updates DOM"
"lisp")))
;; =====================================================================
;; Step 1: Bootstrap CEK to JavaScript
;; =====================================================================
(~docs/section :title "Step 1: Bootstrap CEK to JavaScript" :id "step-1"
(p "Add " (code "frames.sx") " + " (code "cek.sx") " to the JS build pipeline. "
"Currently CEK is Python-only.")
(~docs/subsection :title "1a. platform_js.py — SPEC_MODULES + platform code"
(p "Add to " (code "SPEC_MODULES") " dict:")
(~docs/code :code (highlight
"\"frames\": (\"frames.sx\", \"frames (CEK continuation frames)\"),\n\"cek\": (\"cek.sx\", \"cek (explicit CEK machine evaluator)\"),\n\n# Add ordering (new constant):\nSPEC_MODULE_ORDER = [\"deps\", \"frames\", \"page-helpers\", \"router\", \"signals\", \"cek\"]"
"python"))
(p "Add " (code "PLATFORM_CEK_JS") " constant (mirrors " (code "PLATFORM_CEK_PY") "):")
(~docs/code :code (highlight
"// Primitive aliases used by cek.sx\nvar inc = PRIMITIVES[\"inc\"];\nvar dec = PRIMITIVES[\"dec\"];\nvar zip_pairs = PRIMITIVES[\"zip-pairs\"];\n\nfunction makeCekContinuation(captured, restKont) {\n var c = new Continuation(function(v) { return v !== undefined ? v : NIL; });\n c._cek_data = {\"captured\": captured, \"rest-kont\": restKont};\n return c;\n}\nfunction continuationData(c) {\n return (c && c._cek_data) ? c._cek_data : {};\n}"
"javascript"))
(p "Add " (code "CEK_FIXUPS_JS") " — iterative " (code "cek-run") " override:")
(~docs/code :code (highlight
"cekRun = function(state) {\n while (!cekTerminal_p(state)) { state = cekStep(state); }\n return cekValue(state);\n};"
"javascript")))
(~docs/subsection :title "1b. run_js_sx.py — Update compile_ref_to_js"
(ul :class "list-disc pl-6 mb-4 space-y-1"
(li "Auto-add " (code "\"frames\"") " when " (code "\"cek\"") " in spec_mod_set (mirror Python " (code "bootstrap_py.py") ")")
(li "Auto-add " (code "\"cek\"") " + " (code "\"frames\"") " when " (code "\"dom\"") " adapter included (CEK needed for reactive rendering)")
(li "Use " (code "SPEC_MODULE_ORDER") " for ordering instead of " (code "sorted()"))
(li "Add " (code "has_cek") " flag")
(li "Include " (code "PLATFORM_CEK_JS") " after transpiled code when " (code "has_cek"))
(li "Include " (code "CEK_FIXUPS_JS") " in fixups section when " (code "has_cek"))))
(~docs/subsection :title "1c. js.sx — RENAMES for predicate functions"
(p "Default mangling handles most names. Only add RENAMES where " (code "?")
" suffix needs clean JS names:")
(~docs/code :code (highlight
"\"cek-terminal?\" \"cekTerminalP\"\n\"kont-empty?\" \"kontEmptyP\"\n\"make-cek-continuation\" \"makeCekContinuation\"\n\"continuation-data\" \"continuationData\""
"lisp")))
(~docs/subsection :title "1d. bootstrap_py.py — RENAMES for CEK predicates"
(~docs/code :code (highlight
"\"cek-terminal?\": \"cek_terminal_p\",\n\"kont-empty?\": \"kont_empty_p\",\n\"make-cek-continuation\": \"make_cek_continuation\",\n\"continuation-data\": \"continuation_data\","
"python")))
(~docs/subsection :title "Verification"
(ul :class "list-disc pl-6 mb-4 space-y-1"
(li "Rebootstrap JS: " (code "python3 bootstrap_js.py"))
(li "Check output contains frame constructors + CEK step functions")
(li "Run existing CEK Python tests: " (code "python3 run_cek_tests.py") " (should still pass)"))))
;; =====================================================================
;; Step 2: ReactiveResetFrame + DerefFrame
;; =====================================================================
(~docs/section :title "Step 2: ReactiveResetFrame + DerefFrame" :id "step-2"
(p "New frame types in " (code "frames.sx") " that enable deref-as-shift.")
(~docs/subsection :title "2a. New frame constructors"
(~docs/code :code (highlight
";; ReactiveResetFrame: delimiter for reactive deref-as-shift\n;; Carries an update-fn that gets called with new values on re-render.\n(define make-reactive-reset-frame\n (fn (env update-fn first-render?)\n {:type \"reactive-reset\" :env env :update-fn update-fn\n :first-render first-render?}))\n\n;; DerefFrame: awaiting evaluation of deref's argument\n(define make-deref-frame\n (fn (env)\n {:type \"deref\" :env env}))"
"lisp")))
(~docs/subsection :title "2b. Update kont-capture-to-reset"
(p "Must stop at EITHER " (code "\"reset\"") " OR " (code "\"reactive-reset\"") ":")
(~docs/code :code (highlight
"(define kont-capture-to-reset\n (fn (kont)\n (define scan\n (fn (k captured)\n (if (empty? k)\n (error \"shift without enclosing reset\")\n (let ((frame (first k)))\n (if (or (= (frame-type frame) \"reset\")\n (= (frame-type frame) \"reactive-reset\"))\n (list captured (rest k))\n (scan (rest k) (append captured (list frame))))))))\n (scan kont (list))))"
"lisp")))
(~docs/subsection :title "2c. Helpers to scan for ReactiveResetFrame"
(~docs/code :code (highlight
"(define has-reactive-reset-frame?\n (fn (kont)\n (if (empty? kont) false\n (if (= (frame-type (first kont)) \"reactive-reset\") true\n (has-reactive-reset-frame? (rest kont))))))\n\n;; Returns 3 values: (captured, frame, rest)\n(define kont-capture-to-reactive-reset\n (fn (kont)\n (define scan\n (fn (k captured)\n (if (empty? k)\n (error \"reactive deref without enclosing reactive-reset\")\n (let ((frame (first k)))\n (if (= (frame-type frame) \"reactive-reset\")\n (list captured frame (rest k))\n (scan (rest k) (append captured (list frame))))))))\n (scan kont (list))))"
"lisp"))))
;; =====================================================================
;; Step 3: Make deref a CEK Special Form
;; =====================================================================
(~docs/section :title "Step 3: Make deref a CEK Special Form" :id "step-3"
(p "When " (code "deref") " encounters a signal inside a " (code "reactive-reset")
", perform shift.")
(~docs/subsection :title "3a. Add to special form dispatch in cek.sx"
(p "In the dispatch table (around where " (code "reset") " and " (code "shift") " are):")
(~docs/code :code (highlight
"(= name \"deref\") (step-sf-deref args env kont)"
"lisp")))
(~docs/subsection :title "3b. step-sf-deref"
(p "Evaluates the argument first (push DerefFrame), then decides whether to shift:")
(~docs/code :code (highlight
"(define step-sf-deref\n (fn (args env kont)\n (make-cek-state\n (first args) env\n (kont-push (make-deref-frame env) kont))))"
"lisp")))
(~docs/subsection :title "3c. Handle DerefFrame in step-continue"
(p "When the deref argument is evaluated, decide: shift or return.")
(~docs/code :code (highlight
"(= ft \"deref\")\n (let ((val value)\n (fenv (get frame \"env\")))\n (if (not (signal? val))\n ;; Not a signal: pass through\n (make-cek-value val fenv rest-k)\n ;; Signal: check for ReactiveResetFrame\n (if (has-reactive-reset-frame? rest-k)\n ;; Perform reactive shift\n (reactive-shift-deref val fenv rest-k)\n ;; No reactive-reset: normal deref (scope-based tracking)\n (do\n (let ((ctx (context \"sx-reactive\" nil)))\n (when ctx\n (let ((dep-list (get ctx \"deps\"))\n (notify-fn (get ctx \"notify\")))\n (when (not (contains? dep-list val))\n (append! dep-list val)\n (signal-add-sub! val notify-fn)))))\n (make-cek-value (signal-value val) fenv rest-k)))))"
"lisp")))
(~docs/subsection :title "3d. reactive-shift-deref — the heart"
(~docs/code :code (highlight
"(define reactive-shift-deref\n (fn (sig env kont)\n (let ((scan-result (kont-capture-to-reactive-reset kont))\n (captured-frames (first scan-result))\n (reset-frame (nth scan-result 1))\n (remaining-kont (nth scan-result 2))\n (update-fn (get reset-frame \"update-fn\")))\n ;; Sub-scope for nested subscriber cleanup on re-invocation\n (let ((sub-disposers (list)))\n (let ((subscriber\n (fn ()\n ;; Dispose previous nested subscribers\n (for-each (fn (d) (invoke d)) sub-disposers)\n (set! sub-disposers (list))\n ;; Re-invoke: push fresh ReactiveResetFrame (first-render=false)\n (let ((new-reset (make-reactive-reset-frame env update-fn false))\n (new-kont (concat captured-frames\n (list new-reset)\n remaining-kont)))\n (with-island-scope\n (fn (d) (append! sub-disposers d))\n (fn ()\n (cek-run\n (make-cek-value (signal-value sig) env new-kont))))))))\n ;; Register subscriber\n (signal-add-sub! sig subscriber)\n ;; Register cleanup with island scope\n (register-in-scope\n (fn ()\n (signal-remove-sub! sig subscriber)\n (for-each (fn (d) (invoke d)) sub-disposers)))\n ;; Return current value for initial render\n (make-cek-value (signal-value sig) env remaining-kont))))))"
"lisp")))
(~docs/subsection :title "3e. Handle ReactiveResetFrame in step-continue"
(p "When expression completes normally (or after re-invocation):")
(~docs/code :code (highlight
"(= ft \"reactive-reset\")\n (let ((update-fn (get frame \"update-fn\"))\n (first? (get frame \"first-render\")))\n ;; On re-render (not first), call update-fn with new value\n (when (and update-fn (not first?))\n (invoke update-fn value))\n (make-cek-value value env rest-k))"
"lisp"))
(p (strong "Key:") " On first render, update-fn is NOT called — the value flows back to the caller "
"who inserts it into the DOM. On re-render (subscriber fires), update-fn IS called "
"to mutate the existing DOM.")))
;; =====================================================================
;; Step 4: Integrate into adapter-dom.sx
;; =====================================================================
(~docs/section :title "Step 4: Integrate into adapter-dom.sx" :id "step-4"
(p "Add CEK reactive path alongside existing effect-based path, controlled by opt-in flag.")
(~docs/subsection :title "4a. Opt-in flag"
(~docs/code :code (highlight
"(define *use-cek-reactive* false)\n(define enable-cek-reactive! (fn () (set! *use-cek-reactive* true)))"
"lisp")))
(~docs/subsection :title "4b. CEK reactive attribute binding"
(~docs/code :code (highlight
"(define cek-reactive-attr\n (fn (el attr-name expr env)\n (let ((update-fn (fn (val)\n (cond\n (or (nil? val) (= val false)) (dom-remove-attr el attr-name)\n (= val true) (dom-set-attr el attr-name \"\")\n :else (dom-set-attr el attr-name (str val))))))\n ;; Mark for morph protection\n (let ((existing (or (dom-get-attr el \"data-sx-reactive-attrs\") \"\"))\n (updated (if (empty? existing) attr-name (str existing \",\" attr-name))))\n (dom-set-attr el \"data-sx-reactive-attrs\" updated))\n ;; Initial render via CEK with ReactiveResetFrame\n (let ((initial (cek-run\n (make-cek-state expr env\n (list (make-reactive-reset-frame env update-fn true))))))\n (invoke update-fn initial)))))"
"lisp")))
(~docs/subsection :title "4c. Modify render-dom-element dispatch"
(p "In attribute processing, add conditional:")
(~docs/code :code (highlight
"(context \"sx-island-scope\" nil)\n (if *use-cek-reactive*\n (cek-reactive-attr el attr-name attr-expr env)\n (reactive-attr el attr-name\n (fn () (trampoline (eval-expr attr-expr env)))))"
"lisp"))
(p "Similarly for text positions and conditional rendering."))
(~docs/subsection :title "4d. CEK reactive text"
(~docs/code :code (highlight
"(define cek-reactive-text\n (fn (expr env)\n (let ((node (create-text-node \"\"))\n (update-fn (fn (val)\n (dom-set-text-content node (str val)))))\n (let ((initial (cek-run\n (make-cek-state expr env\n (list (make-reactive-reset-frame env update-fn true))))))\n (dom-set-text-content node (str initial))\n node))))"
"lisp")))
(~docs/subsection :title "4e. What stays unchanged"
(ul :class "list-disc pl-6 mb-4 space-y-1"
(li (code "reactive-list") " — keyed reconciliation is complex; keep effect-based for now")
(li (code "reactive-spread") " — spread tracking is complex; keep effect-based")
(li (code "effect") ", " (code "computed") " — still needed for non-rendering side effects")
(li "Existing " (code "reactive-*") " functions — remain as default path"))))
;; =====================================================================
;; Step 5: Tests
;; =====================================================================
(~docs/section :title "Step 5: Tests" :id "step-5"
(~docs/subsection :title "5a. test-cek-reactive.sx"
(p "Tests:")
(ol :class "list-decimal pl-6 mb-4 space-y-1"
(li (code "deref") " non-signal passes through (no shift)")
(li (code "deref") " signal without reactive-reset: returns value, no subscription")
(li (code "deref") " signal with reactive-reset: shifts, registers subscriber, update-fn called on change")
(li "Expression with deref: " (code "(str \"hello \" (deref sig))") " — continuation captures rest")
(li "Multi-deref: both signals create subscribers, both fire correctly")
(li "Disposal: removing island scope unsubscribes all continuations")
(li "Stale subscriber cleanup: re-invocation disposes nested subscribers")))
(~docs/subsection :title "5b. run_cek_reactive_tests.py"
(p "Mirrors " (code "run_cek_tests.py") ". "
"Loads frames.sx, cek.sx, signals.sx, runs test-cek-reactive.sx.")))
;; =====================================================================
;; Step 6: Browser Demo
;; =====================================================================
(~docs/section :title "Step 6: Browser Demo" :id "step-6"
(p "Demo showing:")
(ul :class "list-disc pl-6 mb-4 space-y-1"
(li "Counter island with implicit reactivity (no explicit effects)")
(li (code "(deref counter)") " in text position auto-updates")
(li (code "(str \"count-\" (deref class-sig))") " in attr position auto-updates")
(li "Side-by-side comparison: effect-based vs continuation-based code")))
;; =====================================================================
;; Multi-Deref Handling
;; =====================================================================
(~docs/section :title "Multi-Deref Handling" :id "multi-deref"
(~docs/code :code (highlight "(str (deref first-name) \" \" (deref last-name))" "lisp"))
(ol :class "list-decimal pl-6 mb-6 space-y-3"
(li (strong "Initial render:") " First " (code "deref") " hits signal → shifts, captures "
(code "(str [HOLE] \" \" (deref last-name))") ". Subscriber registered for "
(code "first-name") ". Returns current value. Second " (code "deref")
" runs (no ReactiveResetFrame between it and the already-consumed one) — "
"falls through to normal scope-based tracking.")
(li (strong "first-name changes:") " Subscriber fires → re-pushes ReactiveResetFrame → "
"re-invokes continuation with new first-name value → second " (code "deref")
" hits ReactiveResetFrame again → shifts, creates NEW subscriber for "
(code "last-name") ". Old last-name subscriber cleaned up via sub-scope disposal.")
(li (strong "last-name changes:") " Its subscriber fires → re-invokes inner continuation → "
"update-fn called with new result."))
(p "This creates O(n) nested continuations for n derefs. Fine for small reactive expressions."))
;; =====================================================================
;; Commit Strategy
;; =====================================================================
(~docs/section :title "Commit Strategy" :id "commits"
(ol :class "list-decimal pl-6 mb-4 space-y-1"
(li (strong "Commit 1:") " Bootstrap CEK to JS (Step 1) — mechanical, independent")
(li (strong "Commit 2:") " ReactiveResetFrame + DerefFrame (Step 2) — new frame types")
(li (strong "Commit 3:") " Deref-as-shift + adapter integration + tests (Steps 3-5) — the core change")
(li (strong "Commit 4:") " Browser demo (Step 6)")))
;; =====================================================================
;; Files
;; =====================================================================
(~docs/section :title "Files" :id "files"
(div :class "overflow-x-auto mb-6"
(table :class "min-w-full text-sm"
(thead (tr
(th :class "text-left pr-4 pb-2 font-semibold" "File")
(th :class "text-left pb-2 font-semibold" "Change")))
(tbody
(tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/platform_js.py")
(td "SPEC_MODULES entries, PLATFORM_CEK_JS, CEK_FIXUPS_JS, SPEC_MODULE_ORDER"))
(tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/run_js_sx.py")
(td "compile_ref_to_js: has_cek, auto-inclusion, ordering, platform code"))
(tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/js.sx")
(td "RENAMES for CEK predicate functions"))
(tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/bootstrap_py.py")
(td "RENAMES for CEK predicates"))
(tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/frames.sx")
(td "ReactiveResetFrame, DerefFrame, has-reactive-reset-frame?, kont-capture-to-reactive-reset"))
(tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/cek.sx")
(td "step-sf-deref, reactive-shift-deref, deref in dispatch, ReactiveResetFrame in step-continue"))
(tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/adapter-dom.sx")
(td "*use-cek-reactive* flag, cek-reactive-attr, cek-reactive-text, conditional dispatch"))
(tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/test-cek-reactive.sx")
(td (strong "New:") " continuation-based reactivity tests"))
(tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/run_cek_reactive_tests.py")
(td (strong "New:") " Python test runner"))
(tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/sx_ref.py")
(td "Rebootstrap (generated)"))
(tr (td :class "pr-4 py-1 font-mono text-xs" "shared/static/scripts/sx-browser.js")
(td "Rebootstrap (generated)"))))))
;; =====================================================================
;; Risks
;; =====================================================================
(~docs/section :title "Risks" :id "risks"
(ol :class "list-decimal pl-6 mb-4 space-y-2"
(li (strong "Performance:") " CEK allocates a dict per step. Mitigated: opt-in flag, tree-walk remains default.")
(li (strong "Multi-deref stale subscribers:") " Mitigated: sub-scope disposal before re-invocation.")
(li (strong "Interaction with user shift/reset:") " " (code "kont-capture-to-reactive-reset")
" only scans for " (code "\"reactive-reset\"") ", not " (code "\"reset\"") ". Orthogonal.")
(li (strong "JS bootstrapper complexity:") " ~10 RENAMES for predicates. Default mangling handles the rest.")))))

View File

@@ -578,6 +578,7 @@
"sx-protocol" (~plans/sx-protocol/plan-sx-protocol-content)
"scoped-effects" (~plans/scoped-effects/plan-scoped-effects-content)
"foundations" (~plans/foundations/plan-foundations-content)
"cek-reactive" (~plans/cek-reactive/plan-cek-reactive-content)
:else (~plans/index/plans-index-content))))
;; ---------------------------------------------------------------------------