Meta-Interpreter Specialization¶
Meta-interpreters (MIs) are one of the most powerful features of logic programming: write a small interpreter, extend it with tracing, counting, depth limiting, or proof trees, and run any object program through it. The cost is interpretation overhead — every goal resolution goes through MatchClause, CopyTerm, list manipulation, and recursive calls.
Partial deduction (partial evaluation for logic programs) eliminates this overhead. Specializing an MI with respect to a known object program produces a residual program structurally identical to the object program, but with the MI's extensions woven directly into the compiled code. This is the first Futamura projection applied to logic programming.
The implementation lives in clausal.logic.specialization.
The meta-interpreters¶
Clausal ships five MIs ported from Triska's acomip, all available via:
| MI | Signature | Extension |
|---|---|---|
Solve |
(GOALS, PROGRAM) |
None — vanilla |
SolveCount |
(GOALS, PROGRAM, COUNT) |
Counts inference steps |
SolveLimit |
(GOALS, PROGRAM, MAX_DEPTH) |
Depth-bounded search |
SolveIterativeDeepening |
(GOALS, PROGRAM) |
Complete search via iterative deepening |
SolveTree |
(GOALS, PROGRAM, TREE) |
Builds proof trees |
Object programs are represented as lists of [Head, BodyGoals] pairs, where terms use list form: f(A, B) becomes ["f", A, B].
Using specialization¶
The -specialize directive¶
-import_from(clausal.examples.metainterpreters, [SolveCount])
NatnumProgram(PROGRAM) <- (
PROGRAM is [
[["natnum", 0], []],
[["natnum", ["s", X]], [["natnum", X]]]
]
)
-specialize(SolveCount, NatnumProgram, alias=SolveCountNatnum)
This produces a new predicate SolveCountNatnum that:
- Resolves
natnumgoals at full compiled speed (noMatchClause, noAppend) - Preserves the counting extension from
SolveCount - Drops the
PROGRAMargument (it was static)
Call it directly:
Directive syntax¶
-specialize(MI, Source, alias=Name)
-specialize(MI, Source, alias=Name, depth=N)
-specialize(MI, Source, alias=Name, cpd=True)
| Parameter | Required | Description |
|---|---|---|
MI |
yes | Meta-interpreter predicate name |
Source |
yes | Nullary predicate returning the object program |
alias |
yes | Name for the specialized predicate |
depth |
no | Deep unfolding depth (default 0 = shallow) |
cpd |
no | Enable conjunctive partial deduction / deforestation (default False) |
The MI pattern (goal-list arg, program arg, extra args, recursive style) is recognized automatically from the clause structure — no separate -metainterpreter declaration is needed.
What the specializer produces¶
Vanilla MI (Solve)¶
Given the natnum program, Solve is specialized to:
SolveNatnum([], ) # base case
SolveNatnum([["natnum", 0], *GOALS]) <- SolveNatnum(GOALS) # fact
SolveNatnum([["natnum", ["s", X]], *GOALS]) <- # rule
SolveNatnum([["natnum", X], *GOALS])
The MatchClause/Append machinery is gone. One clause per object clause, plus the base case.
Counting MI (SolveCount)¶
SolveCountNatnum([], 0)
SolveCountNatnum([["natnum", 0], *GOALS], COUNT) <- (
SolveCountNatnum(GOALS, SUB_COUNT),
COUNT := SUB_COUNT + 1
)
SolveCountNatnum([["natnum", ["s", X]], *GOALS], COUNT) <- (
SolveCountNatnum([["natnum", X], *GOALS], SUB_COUNT),
COUNT := SUB_COUNT + 1
)
The counting logic is woven into each clause. The program argument is gone.
Object programs with builtins¶
When an object program uses goals that aren't defined in the program itself (arithmetic, comparisons, etc.), the specializer generates a catch-all clause that dispatches unknown goals through a runtime resolver:
# Factorial uses gt, sub, mul — these are residual goals
-specialize(Solve, FactorialProgram, alias=SolveFactorial)
The catch-all handles gt, gte, lt, lte, eq, neq, add, sub, mul, div, mod, and true automatically. User-defined predicates in the module dict are also resolved.
Deep unfolding¶
With depth=N, the specializer recursively unfolds body goals that match object-program heads, up to N levels:
Termination is guaranteed by:
- Homeomorphic embedding test — if a newly generated goal is structurally "bigger" than an ancestor in the unfolding tree, unfolding stops for that branch.
- Depth counter — never unfolds more than N levels deep.
- Memoization table — tracks which goal patterns have been specialized to prevent duplicate work.
Deep unfolding inlines deterministic goals (single matching object clause), producing more specific clause heads at the cost of more clauses.
Conjunctive partial deduction (CPD)¶
With cpd=True, the specializer applies deforestation — eliminating intermediate goal-list constructions by unfolding the first goal in a constructed list against all matching object clauses:
Before CPD (Phase 1 output)¶
The body constructs a 3+ element list at runtime, only to immediately decompose it via pattern matching.
After CPD¶
SolveGraph([["path", "a", Y], *GOALS]) <- SolveGraph([["path", "b", Y], *GOALS])
SolveGraph([["path", "b", Y], *GOALS]) <- SolveGraph([["path", "c", Y], *GOALS])
SolveGraph([["path", "b", Y], *GOALS]) <- SolveGraph([["path", "d", Y], *GOALS])
The intermediate list is eliminated — edge matching is inlined directly into the clause heads.
Pre/post-match chaining¶
CPD correctly preserves MI extensions:
- Counting MIs (SolveCount): each inlined step adds its own
COUNT := SUB_COUNT + 1via post-match goal chaining. - Depth-limited MIs (SolveLimit): each inlined step adds its own
MAX > 0, MAX1 := MAX - 1via pre-match goal chaining.
Recognized MI patterns¶
The specializer automatically recognizes two MI styles:
Tail-recursive (Pattern A)¶
Used by Solve, SolveCount, SolveLimit:
MI([], ...) # base
MI([GOAL, *GOALS], PROGRAM, ...) <- ( # recursive
MatchClause(GOAL, BODY, PROGRAM),
Append(BODY, GOALS, ALL_GOALS),
MI(ALL_GOALS, PROGRAM, ...)
)
Split / non-tail-recursive (Pattern B)¶
Used by SolveTree:
MI([], ..., []) # base
MI([GOAL, *GOALS], PROGRAM, ...) <- ( # recursive
MatchClause(GOAL, BODY, PROGRAM),
MI(BODY, PROGRAM, BODY_TREE),
MI(GOALS, PROGRAM, GOALS_TREE)
)
Extra arguments¶
Pre-match goals (before MatchClause, e.g. depth check in SolveLimit) and post-match goals (after the recursive call, e.g. counting in SolveCount) are detected automatically and preserved in specialized clauses.
Pipeline integration¶
Specialization runs as Step 6b in the compile_module() pipeline — after all predicates are compiled (so source programs can be evaluated) and before non-dynamic predicates are locked:
compile_module()
├─ Step 0: _process_imports()
├─ Step 1: run_term_expansion()
├─ Step 1b: run_goal_expansion()
├─ Step 1c: _preregister_specializations() ← empty class created
├─ Step 2: _process_directives()
├─ Step 3: _process_declarations()
├─ Step 4: assert clauses
├─ Step 5: compile predicates
├─ Step 6: wrap tabled
├─ Step 6b: _run_specialization() ← clauses unfolded + compiled
└─ Step 7: lock non-dynamic
Pre-registration at Step 1c creates an empty PredicateMeta class so that later clauses (e.g. Test predicates) can reference the specialized predicate during compilation.
Test coverage
Tests are in tests/test_specialization.py (155 tests) and
tests/test_specialization_pipeline.py (62 tests).
- Phase 0: MI pattern recognition on all five MIs
- Phase 1: Core unfolding with natnum and graph programs
- Phase 3: Residual goal dispatch for builtins and external calls
- Phase 4: Deep unfolding with termination control
- Phase 5: Conjunctive partial deduction / deforestation
- Pipeline: End-to-end
.clausalfixtures for all specialization modes - Equivalence: Specialized predicates produce identical results to unspecialized MIs
Fixtures: specialize_natnum.clausal, specialize_graph.clausal,
specialize_limit.clausal, specialize_builtins.clausal,
specialize_deep.clausal, specialize_cpd.clausal.