The ML port (tinygrad-in-RSScript) reimplements tinygrad's declarative
scheduler/codegen as bespoke imperative recursion. tinygrad expresses every
rewrite as data — PatternMatcher([(UPat(...), lambda node: ...)]) applied by one
generic graph_rewrite(sink, pm, ...) driver with built-in fixpoint +
memoization. The port hand-rolls each pass (indexing_rangeify_rewrite_node,
graph_rewrite_memo_d, …) with manual olds/news memo arrays.
That paraphrase-of-a-declarative-engine is the root cause of the port's worst
bugs. Canonical example (chained-reduce): tinygrad's rule is
(UPat(BUFFERIZE), bufferize_to_store) — it matches a bare BUFFERIZE node and
writes using that node's own ranges. The port re-expressed it as "match
INDEX(STAGE) adjacency, use the consumer's ranges," which (a) conflates
write-ranges with read-ranges and (b) silently breaks when another bespoke pass
(flatten_bufferize) perturbs the graph shape. A real PatternMatcher makes the
scheduler a near-mechanical transliteration of rangeify.py — rules match the
same node shapes tinygrad matches, so this whole class of divergence can't arise.
Paraphrase drifts; transliteration doesn't.
Grounded against the implementation — the original ask over-stated the gap:
- Closures are first-class runtime values:
VmValue::Closure(Rc<VmClosure>),MakeClosure/CallClosurein the reg-VM; closures capture (incl. owned) values;List.fold's|acc, x|is one in use. - An
Fn(...)type exists in the checker (e.g.noescape Fn()), with function-value desugaring (syntax/function_value_desugar.rs). - Tuple destructuring is done:
let (a, b, c) = expr(syntax/desugar.rs::expand_tuple_destructuring). No work needed.
A PatternMatcher is a stored list of closures that outlives its definition
site and is called repeatedly by the rewrite driver. RSScript today restricts
closures to noescape — they may be forwarded down into a call but not
stored up (the diagnostic: "Forwarding a local closure is only allowed when the
target parameter is noescape Fn()"). This is a deliberate review-first
restriction (an escaping closure that captures is a retention/aliasing concern).
So the precise need is owned, escaping closures storable in a collection
(List<owned Fn(UOp) -> UOp> of rules).
Probe result (confirmed empirically). RSScript already has owned Fn(...)
(escaping, move-captures owned values, lowers to Box<dyn FnMut>), but it is
gated: a List<owned Fn(Int)->Int> field / generic arg is rejected with
RS0015 — "owned Fn(...) is only supported as a direct function parameter
type" / "unsupported owned position" — and consequently a closure literal in a
value position is an "unsupported expression", a struct can't hold one, and a
list-fetched closure f(x) "does not resolve". So closures are usable only
inline at the call site of a function with an Fn parameter — not as
first-class values. The gap is a frontend restriction (parser + checker), NOT
a missing capability: the runtime (VmClosure/MakeClosure/CallClosure) and
the lowering (Box<dyn FnMut>, move capture) substrate already exist.
So L1 is "lift a deliberate gate", not "build closures." The sound rule:
storable ⇒ owned ⇒ move-captures owned values ⇒ no aliasing, with the
owned Fn type visible wherever it's stored. That preserves review-first by the
same argument that already makes owned parameters sound (ownership moved in, no
shared mutable state). noescape/read Fn stay parameter-only (storing a
borrow-capturing closure would let a borrow escape — unsound), so the relaxation
is narrow and one-directional.
- L1 — language (keystone): make
owned Fna first-class value. ✅ DONE.owned Fn(args) -> retis now a first-class value: usable as a generic type argument (List<owned Fn ...>), a struct field, alet/localbinding, and a return type; closure literals are accepted in value position; calls on a closure-typed binding resolve (let f = r.fxn; f(args)). owned-only stays the rule (noescape/read Fnremain parameter-only — storing a borrow-capturing closure is unsound). Stored closures lower toRc<dyn Fn(..)>(Clone-able throughList.get, callable via shared ref). L1b ✅ DONE:Fn-type parameters now carryread/mut/takeeffects end to end, so a rule can takeowned Fn(read UOp, mut Ctx) -> Option<UOp>and mutatectxin-body (exclusive borrow for the call, sound + explicit) — the ctx-mutating rules transliterate 1:1. Verified e2e at VM↔compiled parity (tests/vm_eval_parity/owned_fn.rs: toy probe, PatternMatcher shape, andmut Ctxrule). So the keystone is in; L2 is now unblocked and needs no further language change. - L2 — library/runtime: PatternMatcher + graph_rewrite. With L1, build a
UOp/UPattype and a nativegraph_rewrite(sink, rules, bottom_up, name)driver (fixpoint + memoization) plus a gatedtoposort(gate: Fn(UOp)->Bool)— as a runtime/library facility (the Tensor-kernel pattern), NOT more language surface. The port then transliteratesrangeify.pyrule-for-rule. Also retroactively simplifies divmod/symbolic (already faking a fixpoint viagraph_rewrite_memo_d) and codegen. - L3 — ergonomic sugar (independent): iteration. No
for-in / comprehension exists (theforkeyword isprotocol … for Typeimpls).while i < List.len { List.get(...) }is ~3× the Python and a transcription-error source.for x in xs/map/filteris a separate, independently-weighable nicety.
- Done already: tuple destructuring (
let (a,b,c) = …).
The win is declarative fidelity — transliteration over paraphrase eliminates a whole bug class, and it's the highest-leverage thing the port could get. The only deep change is L1 (escaping storable closures); L2 is then a library, L3 is optional sugar. L1 is exactly the kind of expressivity RSScript has deliberately withheld, so the gate is: can an escaping+capturing closure be made explicit enough in the signature to satisfy the review-first contract? If yes, this is the next language-roadmap item. If the answer is "only with implicit retention rules," it stays out and the port keeps a (smaller, well-tested) hand-rolled engine.