Phase 2: IO detection & selective expansion in deps.sx

Extend the spec with IO scanning functions (scan-io-refs, transitive-io-refs,
compute-all-io-refs, component-pure?) that detect IO primitive references in
component ASTs. Components are classified as pure (no IO deps, safe for client
rendering) or IO-dependent (must expand server-side).

The partial evaluator (_aser) now uses per-component IO metadata instead of
the global _expand_components toggle: IO-dependent components expand server-
side, pure components serialize for client. Layout slot context still expands
all components for backwards compat.

Spec: 5 new functions + 2 platform interface additions in deps.sx
Host: io_refs field + is_pure property on Component, compute_all_io_refs()
Bootstrap: both sx_ref.py and sx-ref.js updated with IO functions
Bundle analyzer: shows pure/IO classification per page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 13:19:17 +00:00
parent 652e7f81c8
commit 0ba7ebe349
13 changed files with 409 additions and 46 deletions

View File

@@ -1,25 +1,30 @@
;; Bundle analyzer — live demonstration of Phase 1 component dependency analysis.
;; Bundle analyzer — live demonstration of dependency analysis + IO detection.
;; Shows per-page component bundles vs total, visualizing payload savings.
;; @css bg-green-100 text-green-800 bg-violet-600 bg-stone-200 text-violet-600 text-stone-600 text-green-600 rounded-full h-2.5 grid-cols-3
;; Phase 2 adds IO classification: which components are pure vs IO-dependent.
;; @css bg-green-100 text-green-800 bg-violet-600 bg-stone-200 text-violet-600 text-stone-600 text-green-600 rounded-full h-2.5 grid-cols-3 bg-blue-100 text-blue-800 bg-amber-100 text-amber-800 grid-cols-4
(defcomp ~bundle-analyzer-content (&key pages total-components total-macros)
(defcomp ~bundle-analyzer-content (&key pages total-components total-macros
pure-count io-count)
(~doc-page :title "Page Bundle Analyzer"
(p :class "text-stone-600 mb-6"
"Live analysis of component dependency graphs across all pages in this app. "
"Live analysis of component dependency graphs and IO classification across all pages. "
"Each bar shows how many of the "
(strong (str total-components))
" total components a page actually needs, computed by the "
(a :href "/specs/deps" :class "text-violet-700 underline" "deps.sx")
" transitive closure algorithm.")
" transitive closure algorithm. "
"Phase 2 IO detection classifies each component as pure or IO-dependent.")
(div :class "mb-8 grid grid-cols-3 gap-4"
(div :class "mb-8 grid grid-cols-4 gap-4"
(~analyzer-stat :label "Total Components" :value (str total-components)
:cls "text-violet-600")
(~analyzer-stat :label "Total Macros" :value (str total-macros)
:cls "text-stone-600")
(~analyzer-stat :label "Pages Analyzed" :value (str (len pages))
:cls "text-green-600"))
(~analyzer-stat :label "Pure Components" :value (str pure-count)
:cls "text-blue-600")
(~analyzer-stat :label "IO-Dependent" :value (str io-count)
:cls "text-amber-600"))
(~doc-section :title "Per-Page Bundles" :id "bundles"
(div :class "space-y-3"
@@ -31,7 +36,10 @@
:direct (get page "direct")
:total total-components
:pct (get page "pct")
:savings (get page "savings")))
:savings (get page "savings")
:io-refs (get page "io-refs")
:pure-in-page (get page "pure-in-page")
:io-in-page (get page "io-in-page")))
pages)))
(~doc-section :title "How It Works" :id "how"
@@ -39,7 +47,8 @@
(li (strong "Scan: ") "Regex finds all " (code "(~name") " patterns in the page's content expression.")
(li (strong "Resolve: ") "Each referenced component's body AST is walked to find transitive " (code "~") " references.")
(li (strong "Closure: ") "The full set is the union of direct + transitive deps, following chains through the component graph.")
(li (strong "Bundle: ") "Only these component definitions are serialized into the page payload. Everything else is omitted."))
(li (strong "Bundle: ") "Only these component definitions are serialized into the page payload. Everything else is omitted.")
(li (strong "IO detect: ") "Each component body is scanned for references to IO primitives (frag, query, service, etc.). Components with zero transitive IO refs are pure — safe for client rendering."))
(p :class "mt-4 text-stone-600"
"The analysis handles circular references (via seen-set), "
"walks all branches of control flow (if/when/cond/case), "
@@ -50,18 +59,24 @@
(div :class (str "text-3xl font-bold " cls) value)
(div :class "text-sm text-stone-500 mt-1" label)))
(defcomp ~analyzer-row (&key name path needed direct total pct savings)
(defcomp ~analyzer-row (&key name path needed direct total pct savings
io-refs pure-in-page io-in-page)
(div :class "rounded border border-stone-200 p-4"
(div :class "flex items-center justify-between mb-2"
(div
(span :class "font-mono font-semibold text-stone-800" name)
(span :class "text-stone-400 text-sm ml-2" path))
(div :class "text-right"
(span :class "font-mono text-sm"
(span :class "text-violet-700 font-bold" (str needed))
(span :class "text-stone-400" (str " / " total)))
(span :class "ml-2 inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"
(str savings "% saved"))))
(div :class "flex items-center gap-2"
(span :class "inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
(str pure-in-page " pure"))
(span :class "inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800"
(str io-in-page " IO"))
(div :class "text-right"
(span :class "font-mono text-sm"
(span :class "text-violet-700 font-bold" (str needed))
(span :class "text-stone-400" (str " / " total)))
(span :class "ml-2 inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"
(str savings "% saved")))))
(div :class "w-full bg-stone-200 rounded-full h-2.5"
(div :class "bg-violet-600 h-2.5 rounded-full transition-all"
:style (str "width: " pct "%")))))

View File

@@ -683,43 +683,61 @@
;; Phase 2
;; -----------------------------------------------------------------------
(~doc-section :title "Phase 2: Smart Server/Client Boundary" :id "phase-2"
(~doc-section :title "Phase 2: Smart Server/Client Boundary — IO Detection" :id "phase-2"
(div :class "rounded border border-violet-200 bg-violet-50 p-4 mb-4"
(p :class "text-violet-900 font-medium" "What it enables")
(p :class "text-violet-800" "Formalized partial evaluation model. Server evaluates IO, serializes pure subtrees. The system automatically knows \"this component needs server data\" vs \"this component is pure and can render anywhere.\""))
(div :class "rounded border border-green-300 bg-green-50 p-4 mb-4"
(div :class "flex items-center gap-2 mb-2"
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")
(a :href "/specs/deps" :class "text-green-700 underline text-sm font-medium" "View canonical spec: deps.sx")
(a :href "/isomorphism/bundle-analyzer" :class "text-green-700 underline text-sm font-medium" "Live bundle analyzer with IO"))
(p :class "text-green-900 font-medium" "What it enables")
(p :class "text-green-800" "Automatic IO detection and selective expansion. Server expands IO-dependent components, serializes pure ones for client. Per-component intelligence replaces global toggle."))
(~doc-subsection :title "Current Mechanism"
(p "_aser in async_eval.py already does partial evaluation — IO primitives are awaited and substituted, HTML tags and component calls serialize as SX. The _expand_components context var controls expansion. But this is a global toggle, not per-component."))
(~doc-subsection :title "Approach"
(~doc-subsection :title "IO Detection in the Spec"
(p "Five new functions in "
(a :href "/specs/deps" :class "text-violet-700 underline" "deps.sx")
" extend the Phase 1 walker to detect IO primitive references:")
(div :class "space-y-4"
(div
(h4 :class "font-semibold text-stone-700" "1. Automatic IO detection")
(p "Extend Phase 1 AST walker to check for references to IO_PRIMITIVES names (frag, query, service, current-user, etc.).")
(~doc-code :code (highlight "def has_io_deps(name: str, env: dict) -> bool:\n \"\"\"True if component transitively references any IO primitive.\"\"\"\n ..." "python")))
(h4 :class "font-semibold text-stone-700" "1. IO scanning")
(p (code "scan-io-refs") " walks an AST node, collecting symbol names that match an IO name set. The IO set is provided by the host from boundary declarations (all three tiers: core IO, deployment IO, page helpers).")
(~doc-code :code (highlight "(define scan-io-refs\n (fn (node io-names)\n (let ((refs (list)))\n (scan-io-refs-walk node io-names refs)\n refs)))" "lisp")))
(div
(h4 :class "font-semibold text-stone-700" "2. Component metadata")
(~doc-code :code (highlight "ComponentMeta:\n deps: set[str] # transitive component deps (Phase 1)\n io_refs: set[str] # IO primitive names referenced\n is_pure: bool # True if io_refs empty (transitively)" "python")))
(h4 :class "font-semibold text-stone-700" "2. Transitive IO closure")
(p (code "transitive-io-refs") " follows component deps recursively, unioning IO refs from all reachable components and macros. Cycle-safe via seen-set.")
(~doc-code :code (highlight "(define transitive-io-refs\n (fn (name env io-names)\n ;; Walk deps, scan each body for IO refs,\n ;; union all refs transitively.\n ...))" "lisp")))
(div
(h4 :class "font-semibold text-stone-700" "3. Selective expansion")
(p "Refine _aser: instead of checking a global _expand_components flag, check the component's is_pure metadata:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li "IO-dependent → expand server-side (IO must resolve)")
(li "Pure → serialize for client (let client render)")
(li "Explicit override: :server true on defcomp forces server expansion")))
(h4 :class "font-semibold text-stone-700" "3. Batch computation")
(p (code "compute-all-io-refs") " iterates the env, computes transitive IO refs for each component, and caches the result via " (code "component-set-io-refs!") ". Called after " (code "compute-all-deps") " at component registration time."))
(div
(h4 :class "font-semibold text-stone-700" "4. Data manifest for pages")
(p "PageDef produces a declaration of what IO the page needs, enabling Phase 3 (client can prefetch data) and Phase 5 (streaming)."))))
(h4 :class "font-semibold text-stone-700" "4. Component metadata")
(p "Each component now carries " (code "io_refs") " (transitive IO primitive names) alongside " (code "deps") " and " (code "css_classes") ". The derived " (code "is_pure") " property is true when " (code "io_refs") " is empty — the component can render anywhere without server data."))))
(~doc-subsection :title "Selective Expansion"
(p "The partial evaluator " (code "_aser") " now uses per-component IO metadata instead of a global toggle:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (strong "IO-dependent") " → expand server-side (IO must resolve)")
(li (strong "Pure") " → serialize for client (let client render)")
(li (strong "Layout slot context") " → all components still expand (backwards compat via " (code "_expand_components") " context var)"))
(p "A component calling " (code "(highlight ...)") " or " (code "(query ...)") " is IO-dependent. A component with only HTML tags and string ops is pure."))
(~doc-subsection :title "Platform interface additions"
(p "Two new platform functions each host implements:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
(li "(component-io-refs c) → cached IO ref list")
(li "(component-set-io-refs! c refs) → cache IO refs on component")))
(~doc-subsection :title "Verification"
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li "Components calling (query ...) classified IO-dependent; pure components classified pure")
(li "Existing pages produce identical output (regression)"))))
(li "Components calling (query ...) or (highlight ...) classified IO-dependent")
(li "Pure components (HTML-only) classified pure with empty io_refs")
(li "Transitive IO detection: component calling ~other where ~other calls (current-user) → IO-dependent")
(li "Bootstrapped to both hosts (sx_ref.py + sx-ref.js)")
(li (a :href "/isomorphism/bundle-analyzer" :class "text-violet-700 underline" "Live bundle analyzer") " shows per-page IO classification"))))
;; -----------------------------------------------------------------------
;; Phase 3

View File

@@ -414,7 +414,8 @@
:selected (or (find-current isomorphism-nav-items slug) ""))
:content (case slug
"bundle-analyzer" (~bundle-analyzer-content
:pages pages :total-components total-components :total-macros total-macros)
:pages pages :total-components total-components :total-macros total-macros
:pure-count pure-count :io-count io-count)
:else (~plan-isomorphic-content)))
(defpage bundle-analyzer
@@ -428,7 +429,8 @@
:selected "Bundle Analyzer")
:data (bundle-analyzer-data)
:content (~bundle-analyzer-content
:pages pages :total-components total-components :total-macros total-macros))
:pages pages :total-components total-components :total-macros total-macros
:pure-count pure-count :io-count io-count))
;; ---------------------------------------------------------------------------
;; Plans section

View File

@@ -277,6 +277,8 @@ def _bundle_analyzer_data() -> dict:
env = get_component_env()
total_components = sum(1 for v in env.values() if isinstance(v, Component))
total_macros = sum(1 for v in env.values() if isinstance(v, Macro))
pure_count = sum(1 for v in env.values() if isinstance(v, Component) and v.is_pure)
io_count = total_components - pure_count
pages_data = []
for name, page_def in sorted(get_all_pages("sx").items()):
@@ -286,6 +288,20 @@ def _bundle_analyzer_data() -> dict:
n = len(needed)
pct = round(n / total_components * 100) if total_components else 0
savings = 100 - pct
# IO classification for components in this page
pure_in_page = 0
io_in_page = 0
page_io_refs: set[str] = set()
for comp_name in needed:
val = env.get(comp_name)
if isinstance(val, Component):
if val.is_pure:
pure_in_page += 1
else:
io_in_page += 1
page_io_refs.update(val.io_refs)
pages_data.append({
"name": name,
"path": page_def.path,
@@ -293,6 +309,9 @@ def _bundle_analyzer_data() -> dict:
"needed": n,
"pct": pct,
"savings": savings,
"io-refs": len(page_io_refs),
"pure-in-page": pure_in_page,
"io-in-page": io_in_page,
})
pages_data.sort(key=lambda p: p["needed"], reverse=True)
@@ -301,6 +320,8 @@ def _bundle_analyzer_data() -> dict:
"pages": pages_data,
"total-components": total_components,
"total-macros": total_macros,
"pure-count": pure_count,
"io-count": io_count,
}