You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Defining one piece of runtime behavior — "make upper-case callable from let-go" —
currently has six mechanisms, and which one a given function uses is an accident
of when it was written:
#
Mechanism
Where
Scale
Namespace/name/privacy decided
1
Imperative ns.Def + anonymous Wrap closure
pkg/rt/lang.go
~384 Def sites, ~5,800 lines
at the call site, imperatively
2
//lg:native-annotated named Go funcs → generated registrar
pkg/rt/native_prims.go (+_lifecycle), emitted by cmd/lginterop -primitives
14 directives so far
at the definition, declaratively
3
Direct-call-eligible builtins (dual-use: interpreter registration AND AOT direct-call target)
The cost is exactly the #411 diagnosis: re-homing clojure.string primitives,
adding an io ns, or privatizing the ~82 compiler internals means hand-editing
hundreds of imperative call sites in mechanism 1, because that's where most
behavior still lives. Meanwhile mechanisms 2 and 3 already demonstrate the fix
but cover a sliver of the surface.
Correction to the epic text first
#528 lists scripts/lginterop.lg under "Superseded but still alive." It is not
superseded — it is a live runtime dependency of mechanism 5.cmd/lginterop's
external-package mode reads scripts/lginterop.lg from the repo root on every
run (writeGenScript, cmd/lginterop/main.go:419), concatenates the exports onto
it, and shells out to lg to render the interop file. Deleting it today breaks lginterop -packages. (The #467 corpus exclusion was about ir-stress coverage,
not liveness.) The retirement is still the right goal — but it's a port, not a
deletion, and this proposal sequences it.
Target end state
One mechanism per behavior class, chosen by a decision rule, with the
namespace/name/privacy always declared at the definition:
Host-touching or perf-critical internal primitive → named Go func + //lg: directives (mechanism 2). The registrar generates registration;
re-homing a primitive is a one-line directive edit.
Direct-call-eligible uniform builtins → pkg/rt/builtins/ (mechanism 3),
registered via the same directives so 2 and 3 differ only in signature shape,
not in registration plumbing.
External Go packages → cmd/lginterop -packages (mechanism 5), with the
emitter ported to Go (below).
Dynamic fallback → vm.MustBox/reflection (mechanism 6) stays as-is; it's
the ambient capability the generated code leans on, not a competing registry.
Mechanism 1 — the anonymous-closure ns.Def wall — is the one that goes away,
drained into the classes above.
Slices (each behavior-preserving, PR-sized, in dependency order)
S1 — Make cmd/lginterop self-contained; the emitter STAYS in lg.
The emitter is not rewritten — lg is implemented in lg wherever possible, and
the lg-in-lg bootstrap is a feature to preserve, not debt. What retires is the delivery mechanism: the repo-root file read, the temp-file concatenation, and
the shell-out to a separately built lg binary. Two stages:
S1a (works today): move scripts/lginterop.lg → cmd/lginterop/ lginterop.lg with //go:embed, expose gogen.lg the same way (small embed
var in pkg/rt/gogen), and evaluate in-process — cmd/lginterop already
links pkg/compiler/pkg/vm, so this deletes the exec path and the
repo-root/pre-built-lg requirements without touching the emitter. Exports
can be handed to (lginterop/generate …) as vm values directly instead of
via string serialization.
S1b (end state): generate a pure-Go executable from the lg — AOT-lower gogen.lg + lginterop.lg through the existing lowering pipeline (the core_go_lowered/ pattern), so the shipped binary is pure Go while the
source of truth stays .lg, regenerated by make generate like every other
lowered artifact. Gate: both namespaces lower cleanly under ir-stress lower-go (note chore(ir-stress): exclude scripts/lginterop.lg from corpus — stale gogen/parse-stmts #467 excluded scripts/lginterop.lg from the
corpus as stale, so this bucket must go green first — that work also grows
lowering coverage, which is its own payoff).
Verification for both stages: regenerating interop_xxh3.go is byte-identical.
(Folds in the already-listed dead if smart branch.) The #528 line item is
then satisfied correctly: scripts/lginterop.lg disappears from scripts/
because it moved and got lowered, not because it was rewritten.
S2 — Finish the #411 registrar and drain lang.go by namespace.
Adopt //lg:native for the clojure.string primitive family first (it's the #411 exemplar: veneer over wrongly-homed Go prims), then the io isms, then
privatize bucket ③ wholesale via //lg:private. Each namespace is one PR:
move the closure body to a named func, annotate, regenerate, delete the ns.Def site. Behavior gate: ns-publics diffs empty except intended
privatizations (those get their own PR per the epic's gate).
S3 — Collapse the .lg veneers that exist only to fix naming.
Once S2 re-homes primitives into their right namespaces, veneers like clojure.string's (defn upper-case [s] (core/upper-case (str s))) reduce to
either nothing (primitive registered under the right name directly) or a real .lg implementation (mechanism 4) where #519 wants one.
S4 — Ratchet so the spread doesn't regrow.
A test that counts anonymous vm.NativeFnType.Wrap(func occurrences in lang.go and fails if the count rises (same shape as the bench-ratchet).
New primitives must use directives; the count only goes down.
Non-goals
No behavior changes (per the epic's gate). S2 privatizations that change ns-publics output are split out and flagged.
host_interop.go / MustBox reflection stays; it is the substrate, not a
duplicate.
S1a is small and mechanical (embed + in-process eval), bounded by the golden
byte-identical test. S1b's cost is whatever it takes to get gogen.lg + lginterop.lg green under ir-stress lower-go — that's lowering-coverage
work with value beyond this epic, and S1a doesn't block on it. S2 is
mechanical per-namespace, parallelizable across worktrees. S3 falls out of
S2. S4 is an afternoon.
What "spread out" means concretely
Defining one piece of runtime behavior — "make
upper-casecallable from let-go" —currently has six mechanisms, and which one a given function uses is an accident
of when it was written:
ns.Def+ anonymousWrapclosurepkg/rt/lang.go//lg:native-annotated named Go funcs → generated registrarpkg/rt/native_prims.go(+_lifecycle), emitted bycmd/lginterop -primitivespkg/rt/builtins/.lgcore, compiled to bundle + lowered treepkg/rt/core/*.lg.lgsourcecmd/lginteropmain.go +scripts/lginterop.lg→pkg/rt/interop_<pkg>.gopkg/rt/host_interop.go,vm.MustBoxThe cost is exactly the #411 diagnosis: re-homing
clojure.stringprimitives,adding an
ions, or privatizing the ~82 compiler internals means hand-editinghundreds of imperative call sites in mechanism 1, because that's where most
behavior still lives. Meanwhile mechanisms 2 and 3 already demonstrate the fix
but cover a sliver of the surface.
Correction to the epic text first
#528 lists
scripts/lginterop.lgunder "Superseded but still alive." It is notsuperseded — it is a live runtime dependency of mechanism 5.
cmd/lginterop'sexternal-package mode reads
scripts/lginterop.lgfrom the repo root on everyrun (
writeGenScript, cmd/lginterop/main.go:419), concatenates the exports ontoit, and shells out to
lgto render the interop file. Deleting it today breakslginterop -packages. (The #467 corpus exclusion was about ir-stress coverage,not liveness.) The retirement is still the right goal — but it's a port, not a
deletion, and this proposal sequences it.
Target end state
One mechanism per behavior class, chosen by a decision rule, with the
namespace/name/privacy always declared at the definition:
.lgcore (mechanism 4).Aligns with Write more of let-go in let-go: capability unlocks for .lg + AOT lowering #519; every migration here shrinks the Go surface.
//lg:directives (mechanism 2). The registrar generates registration;re-homing a primitive is a one-line directive edit.
pkg/rt/builtins/(mechanism 3),registered via the same directives so 2 and 3 differ only in signature shape,
not in registration plumbing.
cmd/lginterop -packages(mechanism 5), with theemitter ported to Go (below).
vm.MustBox/reflection (mechanism 6) stays as-is; it'sthe ambient capability the generated code leans on, not a competing registry.
Mechanism 1 — the anonymous-closure
ns.Defwall — is the one that goes away,drained into the classes above.
Slices (each behavior-preserving, PR-sized, in dependency order)
S1 — Make
cmd/lginteropself-contained; the emitter STAYS in lg.The emitter is not rewritten — lg is implemented in lg wherever possible, and
the lg-in-lg bootstrap is a feature to preserve, not debt. What retires is the
delivery mechanism: the repo-root file read, the temp-file concatenation, and
the shell-out to a separately built
lgbinary. Two stages:scripts/lginterop.lg→cmd/lginterop/ lginterop.lgwith//go:embed, exposegogen.lgthe same way (small embedvar in
pkg/rt/gogen), and evaluate in-process —cmd/lginteropalreadylinks
pkg/compiler/pkg/vm, so this deletes the exec path and therepo-root/pre-built-
lgrequirements without touching the emitter. Exportscan be handed to
(lginterop/generate …)as vm values directly instead ofvia string serialization.
gogen.lg+lginterop.lgthrough the existing lowering pipeline (thecore_go_lowered/pattern), so the shipped binary is pure Go while thesource of truth stays
.lg, regenerated bymake generatelike every otherlowered artifact. Gate: both namespaces lower cleanly under
ir-stress lower-go(note chore(ir-stress): exclude scripts/lginterop.lg from corpus — stale gogen/parse-stmts #467 excludedscripts/lginterop.lgfrom thecorpus as stale, so this bucket must go green first — that work also grows
lowering coverage, which is its own payoff).
Verification for both stages: regenerating
interop_xxh3.gois byte-identical.(Folds in the already-listed dead
if smartbranch.) The #528 line item isthen satisfied correctly:
scripts/lginterop.lgdisappears fromscripts/because it moved and got lowered, not because it was rewritten.
S2 — Finish the #411 registrar and drain
lang.goby namespace.Adopt
//lg:nativefor theclojure.stringprimitive family first (it's the#411 exemplar: veneer over wrongly-homed Go prims), then the
ioisms, thenprivatize bucket ③ wholesale via
//lg:private. Each namespace is one PR:move the closure body to a named func, annotate, regenerate, delete the
ns.Defsite. Behavior gate:ns-publicsdiffs empty except intendedprivatizations (those get their own PR per the epic's gate).
S3 — Collapse the
.lgveneers that exist only to fix naming.Once S2 re-homes primitives into their right namespaces, veneers like
clojure.string's(defn upper-case [s] (core/upper-case (str s)))reduce toeither nothing (primitive registered under the right name directly) or a real
.lgimplementation (mechanism 4) where #519 wants one.S4 — Ratchet so the spread doesn't regrow.
A test that counts anonymous
vm.NativeFnType.Wrap(funcoccurrences inlang.goand fails if the count rises (same shape as the bench-ratchet).New primitives must use directives; the count only goes down.
Non-goals
ns-publicsoutput are split out and flagged.host_interop.go/MustBoxreflection stays; it is the substrate, not aduplicate.
:skip-gomarkers stay intheir own lanes; this proposal only touches how behaviors are registered,
not how they're compiled or lowered.
Effort sketch
S1a is small and mechanical (embed + in-process eval), bounded by the golden
byte-identical test. S1b's cost is whatever it takes to get
gogen.lg+lginterop.lggreen underir-stress lower-go— that's lowering-coveragework with value beyond this epic, and S1a doesn't block on it. S2 is
mechanical per-namespace, parallelizable across worktrees. S3 falls out of
S2. S4 is an afternoon.