Execution Meta-Programming

page 1 of 5

A Simple Language for Inspecting Execution

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.

Basics of Term Rewriting

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.

Pattern Matching & Variables

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.

Functions as Sugar

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)

Evaluation = Repeated Rewriting

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.

How Meta Works

Scopes

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.

Meta-rules

A meta-rule observes the engine as it works. It reacts to two events:

  • reduction(?step, ?old, ?new, ?kind) — a term was rewritten
  • result(?step, ?call, ?val) — a function call finished evaluating
rule 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.

Writing to @rules

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.

Tracing Fibonacci

...
Step 0

Ghost Trail

frame 0