Skip to content

Lambdas (Goal Closures)

Lambdas are anonymous clauses that can be passed as arguments to higher-order predicates. They use the same head <- body arrow syntax as clause definitions. Variables from the enclosing clause are captured implicitly — no special declarations are needed. Lambdas are the primary mechanism for higher-order logic programming in clausal.

The implementation lives in clausal/logic/compiler.py (codegen), clausal/templating/term_rewriting.py (term transformation), and clausal/logic/builtins.py (CallGoal builtins).


Syntax

Arrow lambdas use head <- body — the same syntax as clause definitions, making them anonymous clauses:

# One-arg lambda
apply_val(RESULT, VAL) <- CallGoal((X <- (RESULT is X)), VAL)

# Two-arg lambda with arithmetic
test(R) <- CallGoal(((X, Y) <- (Y := X + 1)), 5, R)

# Zero-arg lambda
run_goal(RESULT) <- CallGoal((() <- (RESULT is 42)))

# Captured variable from enclosing clause
add_z(Z, R) <- CallGoal((X <- (R := X + Z)), 10)

The head is a variable (single param) or tuple of variables (multiple params). The body is any goal expression — unification, arithmetic evaluation, predicate calls, or conjunctions.

Conjunction bodies

Multiple goals are separated with , inside (...):

transform(R) <- CallGoal(((X, Y) <- (T := X + 1, Y := T * 2)), 5, R)

Why arrow syntax?

Arrow lambdas are homoiconic — they look like the clause definitions they represent:

# Clause definition (statement level)
double(X, Y) <- (Y := X + X)

# Anonymous clause (expression level) — same syntax
apply_double(V, R) <- CallGoal((X <- (double(X, R))), V)

Python's lambda syntax is not supported in .clausal files.


Variable capture

Lambdas capture variables from the enclosing clause implicitly, using Python's native closure semantics. No in declaration or explicit free-variable marking is needed.

# Z and RESULT are captured from the enclosing clause head.
# X is a lambda parameter.
captured_add(Z, RESULT) <- CallGoal((X <- (RESULT := X + Z)), 10)

When queried as captured_add(3, R), this yields R = 13: the lambda captures Z (bound to 3) and RESULT from the clause, receives X = 10 as a parameter, and evaluates RESULT := 10 + 3.

How capture works

Variables fall into three categories inside a lambda body:

Category Example How it works
Parameter X in X <- (...) Function argument of the compiled lambda
Captured Z used in body but defined in enclosing clause Python closure over the enclosing scope's Var
Body-local T first appearing inside the lambda body Fresh Var() allocated inside the lambda function

Captured variables share the same Var object as the enclosing clause. When the enclosing clause binds Z to a value, the lambda sees that binding through the shared reference. This is correct because Python closures capture by reference, and logic variables are mutable (via trail-based binding).

Parameter shadowing

If a lambda parameter has the same name as an enclosing variable, the parameter shadows it:

# X in the lambda body refers to the parameter, not the clause-head X
test(X) <- CallGoal((X <- (X is 42)), _)

Calling lambdas

Lambdas are invoked with the CallGoal builtin, which takes a goal closure and optional extra arguments:

Builtin Usage
CallGoal/1 CallGoal(Goal) — call a zero-arg closure
CallGoal/2 CallGoal(Goal, Arg1) — call with one extra arg
CallGoal/3 CallGoal(Goal, Arg1, Arg2) — call with two extra args
CallGoal/4..8 Higher arities, same pattern
Call/1..8 Aliases for CallGoal/1..8

The extra arguments are passed as positional parameters to the lambda:

# skip
# lambda receives X = 5
CallGoal((X <- (X > 0)), 5)

# lambda receives X = 5, Y = RESULT
CallGoal(((X, Y) <- (Y := X * 2)), 5, RESULT)

Multi-solution lambdas

A lambda can produce multiple solutions. If the lambda body calls a multi-solution predicate, each solution is propagated to the caller:

color("red"),
color("green"),
color("blue"),

get_color(C) <- CallGoal((X <- (color(X), C is X)), _)

Querying get_color(C) yields three solutions: C = "red", C = "green", C = "blue".


Calling predicates from lambdas

Lambda bodies can call user-defined predicates. Internally, this works through the _tramp_call bridge, which adapts between the lambda's simple-mode execution and the predicate's trampoline-mode dispatch:

double(X, Y) <- (Y := X + X)

apply_double(VAL, RESULT) <- CallGoal((X <- (double(X, RESULT))), VAL)

This is transparent — no special syntax is needed. The bridge (_tramp_call) handles the protocol mismatch automatically.


Compilation

Lambdas compile to simple-mode Python generator functions. A lambda like:

# skip
(X, Y) <- (Y := X + Z)

compiles to approximately:

def _lambda_0(X_, Y_, trail, k):
    # body compilation (simple mode)
    _arith_result = eval_arith(X_) + eval_arith(Z_)  # Z_ is a closure var
    _m = trail.mark()
    if unify(Y_, _arith_result, trail):
        yield None  # solution
    trail.undo(_m)
    return; yield  # ensure generator

Key details: - Parameters become function arguments (not Var allocations) - Captured variables are Python closure references to the enclosing scope's Vars - Body-local variables get fresh Var() allocations inside the function - The function yields None per solution (simple-mode protocol) - Predicate calls in the body go through _tramp_call to bridge to trampoline mode

Lambda hoisting

When a lambda appears as an argument to a predicate call, the compiler hoists it: the FunctionDef is emitted before the call statement, and the lambda argument is replaced with a reference to the generated function name. This means the lambda is compiled once and called by reference.


Anonymous variables

_ in a lambda body is the anonymous variable — each occurrence is a fresh Var():

get_color(C) <- CallGoal((X <- (color(X), C is X)), _)

Here _ as the second arg to CallGoal is a fresh throwaway variable.


Lambdas with meta-predicates

Lambdas combine naturally with FindAll, BagOf, SetOf, and ForAll. The goal argument to these meta-predicates can be any goal expression, including lambda calls:

# Collect squares of a list using a lambda
squares(NS, SQS) <- (
    FindAll(
        SQ,
        (In(X, NS), SQ := X * X),
        SQS,
    )
)

# Filter with ForAll
all_positive(NS) <- ForAll(In(X, NS), X > 0)

Since FindAll and friends are compiler special forms, the goal argument is compiled inline — it is not passed as a closure. This means any goal expression works directly as the second argument, without needing to wrap it in a lambda.


Lambdas with higher-order list predicates

The higher-order list builtins — MapList, Filter, Exclude, FoldLeft — take a callable goal as a runtime argument. This can be a lambda (goal closure) or a predicate reference (builtin or user-defined):

# skip
# Builtin predicates can be passed directly — no lambda needed
all_numbers(XS) <- MapList(IsNumber, XS)
keep_ints(XS, IS) <- Filter(IsInt, XS, IS)
incremented(XS, YS) <- MapList(Succ, XS, YS)

When the goal logic is more complex than a single predicate call, lambdas are the natural choice:

# MapList/3 — double every element
doubles(XS, YS) <- MapList(((X, Y) <- (Y := X * 2)), XS, YS)

# MapList/2 — check all positive
all_pos(XS) <- MapList((X <- (X > 0)), XS)

# Filter/3 — filter positive elements
positives(XS, PS) <- Filter((X <- (X > 0)), XS, PS)

# Exclude/3 — remove even elements
remove_evens(XS, RS) <- Exclude((X <- (M := X % 2, M is 0)), XS, RS)

# FoldLeft/4 — sum a list
fold_sum(XS, S) <- FoldLeft(((E, A, R) <- (R := A + E)), XS, 0, S)

All higher-order list predicates use committed choice — they take the first solution from the goal for each element. This is consistent with the Pythonic philosophy and sufficient for lambda goals, which are typically deterministic.


Limitations

  • Lambdas are only supported as predicate call arguments. Using a lambda in other positions (e.g., X is (X <- ...)) raises NotImplementedError.
  • Nested lambdas are supported for variable capture but are an edge case. Inner lambdas can reference outer lambda parameters via closure.
  • No pattern-matching on parameters. Lambda parameters are positional arguments, not patterns. Use a predicate clause for pattern matching.

Python API

Lambdas are a .clausal file feature — they are compiled from source by the term transformer and compiler. From pure Python, you can construct the equivalent term tree manually:

from clausal.pythonic_ast import nodes as sa
from clausal.terms import Evaluate, Add, LoadName
from clausal.logic.variables import Var

result = Var()
lam = sa.Lambda(
    params=sa.Params(params=[sa.PosOrKwParam(name="X_")]),
    body=Evaluate(left=result, right=Add(left=LoadName(name="X_"), right=1)),
)

In practice, lambdas are most naturally written in .clausal files where the term transformer handles the translation automatically.


Test coverage

Tests are in tests/test_lambdas.py and tests/test_higher_order.py.

  • TermTransformer: arrow syntax produces Lambda nodes, param generates LoadName (not Var), captures enclosing Var, body vars don't leak, nested lambda capture, anonymous _, Python lambda rejected
  • Compiler: produces FunctionDef, params as function args, captured vars as closure refs, conjunction flattening
  • Runtime: CallGoal/1..8 and Call/1..8 with zero to seven extra-arg closures, failing closure, multi-solution closure
  • Compiled execution: lambda with arithmetic body, captured var, unification body, failing body, conjunction body
  • Import integration: .clausal file with unification, captured head var, conjunction, zero-arg, predicate calls, multi-solution, := arithmetic
  • Higher-order builtins: MapList/2 (all succeed, one fails, empty list, non-list, non-callable), MapList/3 (double, empty, fail mid-list), Filter/3 (filter positive, all/none match, empty), Exclude/3 (mirror of Filter), FoldLeft/4 (sum, product, empty, fail mid-fold)