Cross-Stage Persistence
lift turns a value into syntax. Some values should not become syntax
— they are too big to copy, or their identity matters, or they exist
only in the running system. Cross-stage persistence is the other way
for a value to enter generated code: as a reference to the live
value, carried inside the residual program.
lift-ref
(lift-ref name v) produces code that, when run, yields the live
value of v itself — not a syntactic reconstruction:
(lift-ref 'p (cons 1 2))
;=> #<code 1 nodes>
(run 0 (lift-ref 'p (cons 1 2)))
;=> (1 . 2)
The name operand doubles as the stage dispatch (code there
residualizes the lift-ref itself, one stage up). The difference from
lift is identity: the persisted value crosses the stage boundary as
itself. A pair containing a cell demonstrates it — lift refuses such
a value outright, while lift-ref carries the live cell through, still
connected to its store:
(let c (cell-new 5)
(lift (cons c nil)))
;! staging error: lift Tup: car must be Code, got Cell(0)
(let c (cell-new 5)
(let p (cons c nil)
(cell-read (car (run 0 (lift-ref 'p p))))))
;=> 5
Inside the expression tree the persisted value sits in the one node
with no surface syntax — the reference’s proc (Part I’s reference
chapter). Residual programs are otherwise printable source; a
persisted value is the exception, a live object riding in the syntax.
Staged log
log persists rather than lifts for the same reason. When log
residualizes — code stage dispatch — its value operand may be any
value at all, and demanding code there would make the debugging tool
unusable in exactly the stage-oblivious code it exists to observe. So
a non-code operand is embedded by reference, and the generated program
prints it when it runs:
(run 0 (log (lift 0) (cons 1 2)))
;; prints: [log] (1 . 2)
;=> (1 . 2)
The [log] line is printed by the generated program during the
run, not by the generator.
What cannot cross: cells
Cells refuse both crossings, in opposite directions, and both refusals protect the same invariant.
A cell cannot be lifted — Part I stated the rule; the reason is that folding a cell’s identity into generated syntax would freeze a reference whose whole point is to be written later:
(lift (cell-new 0))
;! staging error: cannot lift a cell: cells never fold under staging
Staged cell operations residualize instead, so generated code reads and writes cells when it runs, against whatever the store holds then:
(let f (run 0 (lift (lambda _ c (cell-read c))))
(let c (cell-new 9)
(f c)))
;=> 9
The compiled function reads the cell at call time — the 9 was
written after the function was generated.
In the other direction, code cannot be written into a cell:
(let c (cell-new 0)
(cell-set! c (lift 3)))
;! type error in cell-set!: expected non-Code value (cells never contain Code), got (Cell, Code(1 nodes))
This is a scope-extrusion guard. A code value is a fragment of a program under construction, full of residual variables that mean something only inside their reify scope; a cell would let it outlive that scope and surface in a program where those variables bind nothing. Cells hold values, never code — which, with “cells never fold”, gives the two-sided rule the tower of Part IV is built on: mutable state stays live, staging works around it, and a compiled tower still sees every write.