I’ve always enjoyed things like Bret Victor’s Learnable Programming or his impressive programming demos in Inventing on Principle. There have been a number of things that try to add more of these direct manipulation bits into programming languages (Maniposynth, LiveLits). But I’m interested in something slightly different. I’ve always felt there was a gap that wasn’t being filled.
How do we take a normal program not meant to be visual, not created with the idea of being an explorable explanation and layer on this kind of diagramming? The way I see people create these is by an external approach. They will create their own little vm that will run the program and from the outside capture all the state they need to do the visualization. What if instead, all of that could be done inside one programming language? What if the actual steps a program takes to execute could be accessed in the language itself?
What follows is a quick exploration of that idea. There are so many ways it takes shortcuts, so many rough edges. So many semantic questions I need to resolve if it were to be more than this demo. But I think the small amount shows the idea. The next two pages are a quick tutorial on the language and the final two are little demos showing how we can write code not designed to be inspected, and inspect the execution and play it back.
Rules4 programs are built from terms — nested tree structures like fib(5) or circle(100, 200, 15). A term is either a number, a symbol, or a function call: a name applied to arguments.
A rule says “when you see this pattern, replace it with that.” Variables start with ? and match anything:
rule(double(?x), ?x + ?x) # double(5) => 5 + 5
Rules are tried top-to-bottom. The first matching pattern wins.
Writing fn definitions is just shorthand for rules:
fn fib(0) = 0 # same as: rule(fib(0), 0) fn fib(1) = 1 fn fib(?n) = fib(?n - 1) + fib(?n - 2)
The engine rewrites terms over and over until nothing changes. fib(3) becomes fib(2) + fib(1), then each part rewrites further, until only numbers remain and arithmetic produces the final answer.
There are no statements, assignments, or side effects — just terms being rewritten by pattern-matching rules.
Every rule lives in a scope. Ordinary rules live in the default scope. Prefixes like @meta, @dom, and @draw name other scopes.
Scopes are isolated: rules in @draw don’t interfere with rules in the default scope.
A meta-rule observes the engine as it works. It reacts to two events:
reduction(?step, ?old, ?new, ?kind) — a term was rewrittenresult(?step, ?call, ?val) — a function call finished evaluatingrule tracer : @meta -> @rules {
result(?step, fib(?n), ?val) =>
rule(fib(?n), ?val) # inject a memo rule!
}The header : @meta -> @rules means: “observe from @meta, write results into @rules.” The arrow target determines where the output goes.
When a meta-rule produces rule(fib(3), 2) and its target is @rules, that new rule is injected into the default scope. Future evaluations of fib(3) will match immediately — memoization for free.
Meta-rules can target any scope: @dom to produce UI, @draw to capture drawing commands, or even @rules to modify the program’s own rules while it runs.