Clausal — Syntax Design¶
Clausal does not follow ISO Prolog syntax. It uses Python syntax throughout — all clausal code is valid Python, acceptable to the Python parser without modification. No separate parser is needed. This was a fundamental design decision: Python programmers should not have to learn a foreign syntax to use logic programming.
The tradeoffs this creates (mainly around the unification operator and some operators) are documented below, with the reasoning behind each choice.
Quick navigation: Variables · Operators · Clauses & Rules · Lists · Arithmetic · Constraints · DCGs · Cheatsheet
The key rule¶
Expression statements ending in , are interpreted as logical terms (facts or goals).
In standard Python, an expression statement that produces a tuple has no effect whatsoever. This means trailing commas can be used as delimiters without disturbing any existing Python code. It also means logic terms are easy to cut, copy, paste, and indent — they don't require . terminators that would conflict with Python's attribute access syntax.
Escape operators¶
Three double-prefix operators demarcate the boundary between Python and logic code. They must not be split with spaces (e.g. - - would be parsed as double negation).
| Operator | Meaning |
|---|---|
--expr |
Python expression embedded inside a logic term |
++expr |
In Python context: logic term inside a Python expression. In .clausal context: evaluate Python expression at search time |
~~expr |
Capture expression as a simple_ast AST node (works anywhere) |
-- was chosen because:
- it doesn't introduce a new keyword or clobber any identifier
- double negation is rare in Python code (and where it occurs, spacing makes it legible)
- it is visually prominent and quick to type
Example:
# skip
# Logic term containing a Python value:
point(--x_coord, --y_coord),
# Python expression containing a logic term:
my_term = ++foo(X, bar(Y))
# Capture Python code as AST without running it:
ast_node = ~~(x + y * z)
Logic variables¶
Two conventions are recognised:
Trailing single underscore — any identifier whose last character is _, excluding dunders (__) and the bare anonymous variable _:
ALL-CAPS — any identifier where every cased character is uppercase and there is at least one cased character (underscores and digits are allowed inside):
Both styles may be used in the same file. ALL-CAPS is the preferred style for new code; trailing-underscore remains valid.
A single _ is the anonymous variable — it never stores a value, and unification against it always succeeds (matching Python's existing convention).
Logic variables are not declared; they come into existence by appearing in logical context. They work differently from Python variables: they can be unbound, and their bindings are undone on backtracking. This difference warrants a clear visual marker.
Why not titlecase (the Prolog convention)?
- Python programmers associate titlecase with class names — static, global, noun-like. This is actually close to how atoms behave, not variables.
- ALL-CAPS is used in many languages for constants and distinguished names; here it marks the variable role in the logic sense.
- Single letters like X, Y, N are universally understood as logic variables from mathematics.
Atoms¶
Inside a logical term:
- identifiers in TitleCase or lowercase that are not logic variable names are atoms
- string literals are atoms (except those prefixed with u"...")
Atoms that conflict with Python built-ins or that use non-identifier characters are written as strings: '+', 'is not', 'max'.
Builtin predicate naming¶
All built-in predicates use PascalCase (e.g. FindAll, In, Assert, IsVar). This is a deliberate design choice — not aesthetic — with two goals:
-
Avoid Python keyword conflicts. Many natural predicate names are Python reserved words:
not,in,is,and,or,if,for,assert,lambda,global,return,yield. A Prolog-style lowercase predicate namedinornotwould be a syntax error the moment it appears as a function call in a clause body. -
Avoid Python builtin conflicts. Names like
abs,all,any,callable,filter,float,int,map,max,min,set,str,sum,varare Python builtins that shadow (or would shadow) predicates if used lowercase.
PascalCase keeps the builtin namespace cleanly separate from both Python keywords and user-defined (lowercase) predicates. User predicates are written in lowercase or snake_case as usual; they never conflict with builtins.
Unification¶
Unification is written with is:
Why is rather than =?
- = is Python's assignment operator and cannot appear in expressions
- is expresses the same concept in English — two things being the same — and Python programmers understand it
X is not Y posts a disequality constraint (dif/2): X and Y must end up with different values. This is lazily checked — the constraint is re-evaluated each time either variable gets bound. If they become equal, the constraint fails and the search backtracks. If they remain different, the constraint is satisfied and dropped. See constraints.md for details.
not (X is Y) is the immediate check (Prolog \=/2): it fails if X and Y can unify right now, regardless of future bindings. Use this when you want point-in-time semantics.
The corresponding AST node is Unify(left, right). Disequality is DoesNotUnify(left, right).
Arithmetic binding¶
To evaluate an arithmetic expression and bind the result to a variable, use the walrus operator :=:
N1 := N - 1 evaluates N - 1 as Python arithmetic and unifies the result with N1. This is equivalent to Prolog's is operator. No extra parentheses are needed: clause bodies are already inside (...), and :='s RHS is a test expression in Python's grammar so it does not consume the comma that follows.
The distinction from is:
- X is Y — pure structural unification; neither side is evaluated arithmetically
- (X := expr) — expr is evaluated as arithmetic before unification; X should be unbound
The corresponding AST node is Evaluate(left, right). The compiler applies arith_to_ast_expr to the right side, producing native Python arithmetic that is evaluated before unify is called.
Comparison operators (CLP(FD))¶
The comparison operators ==, !=, <, >, <=, >= are CLP(FD) (Constraint Logic Programming over Finite Domains) operators. They post constraints on integer variables rather than performing immediate checks.
When both sides are ground (no unbound Vars), the operators fall back to direct Python comparison — 3 == 3 is True, 3 < 2 is False — so existing ground arithmetic code works unchanged.
When at least one side is an unbound Var, a CLP(FD) constraint is posted:
- X == 5 narrows X's domain to {5} (and binds it)
- 1 <= X and X <= 10 constrain X's domain to [1, 10]
- X != 3 removes 3 from X's domain
- X < Y narrows X's upper bound and Y's lower bound
| Operator | CLP(FD) meaning |
|---|---|
== |
Arithmetic equality constraint |
!= |
Arithmetic disequality constraint |
< > <= >= |
Comparison constraints (narrow domain bounds) |
The old structural-equality behaviour of == is available as the builtin Equivalent(X, Y). Use Equivalent when comparing non-integer terms where CLP(FD) semantics are not appropriate.
See constraints.md for the full CLP(FD) design, including domain representation, propagation, and labeling.
Horn clauses¶
The <- operator denotes a Horn clause (rule). It will never be added to Python's expression grammar because it conflicts with x < -y (less-than applied to a negated value) — but only when there is no surrounding whitespace. With whitespace, it is unambiguous and parseable.
Body style¶
The body after <- must be one of:
- A single call — no parentheses needed: ```clausal
skip¶
sorted_asc([_]), palindrome(XS) <- reverse(XS, XS) ```
- A bare name — no parentheses needed: ```clausal
skip¶
always_true <- true, ```
- Anything else — parenthesized: ```clausal
skip¶
safe_max(X, Y, X) <- (X >= Y) fib(N, RESULT) <- ( N > 1, N1 := N - 1, N2 := N - 2, fib(N1, A), fib(N2, B), RESULT := A + B ) ```
This rule exists because Python's parser sees <- as < followed by unary -. When the body contains operators (+, <, and, or, not, etc.), the - gets absorbed into the body expression and the AST is silently mangled. Parentheses force Python to treat the body as a single grouped expression, keeping the - at the top where the term rewriter can find it. Calls and bare names are safe without parentheses because they bind tighter than unary -.
Attempting to write an unparenthesized operator body produces a clear error:
# skip
SyntaxError: clause body must be parenthesized or a single call:
write head <- (body) or head <- goal(X)
Conjunction style¶
Multiple goals in a body are separated by commas, with each goal on its own line:
Inside sub-expressions like not (...) or ... or ..., use and instead of commas — commas inside these would be parsed as Python tuples:
Facts (trivially true rules) are written without a body:
Grammar rules (Definite Clause Grammars):
Lists¶
# skip
[] # empty list (singleton)
[a, 1, X] # a simple list
[FIRST, *REST] # head/tail decomposition
[*BEFORE, PIVOT, *AFTER] # multiple spread patterns
Partial lists (Prolog [H|T] where T is a variable) use Python's * spread syntax rather than |. The empty list is a singleton — unlike Python, two [] literals are the same object.
Dicts¶
Python dict literals in .clausal files create DictTerm objects — unification-aware dictionaries. Keys must be ground; values may be logic variables.
# Ground dict fact
point({"x": 0, "y": 0}),
# Dict pattern in head — X binds during unification
get_x({"x": X, "y": Y}, X),
# Dict construction in body
make_point(X, Y, P) <- (P is {"x": X, "y": Y})
# Nested dicts
get_city({"address": {"city": C}}, C),
Two dicts unify iff they have the same keys and values unify pairwise. A variable unifies with a dict by binding to it.
See dicts_sets.md for the full design.
Sets¶
Python set literals in .clausal files create SetTerm objects — unification-aware sets. Elements must be ground (hashable).
Two sets unify iff they contain the same elements (order irrelevant). Variables in set elements are not supported.
See dicts_sets.md for details.
Strings¶
Strings prefixed with u"" are lists of character atoms:
All list operations apply to strings. Plain string literals (without u) are atoms.
F-strings¶
Python f-strings work naturally in .clausal files. Logic variables are auto-dereferenced at search time — bound variables interpolate their value, unbound variables show _N.
greet(NAME) <- Writeln(f"Hello, {NAME}!")
show_pair(X, Y) <- Writeln(f"{X} and {Y}")
# Format specs work too
show_price(ITEM, PRICE) <- Writeln(f"{ITEM}: ${PRICE:.2f}")
Under the hood, f-strings in .clausal files are compiled to deferred PyThunk lambdas during AST transformation. Logic variable names become lambda parameters; the compiler emits calls with deref()'d values at search time.
Simple variable references like f"{X}" and f"{NAME}" work correctly. Format specs (:.2f, :>10, etc.) and conversions (!r, !s) are fully supported. Python expressions inside f-strings (like f"{len(L)}" or f"{S.upper()}") also work — the entire f-string is wrapped in a lambda that receives dereferenced values.
Python interop — ++() escape¶
The ++() operator evaluates an arbitrary Python expression at search time. Logic variables inside the expression are automatically dereferenced.
As a value (inside is):
# Call a Python builtin
list_len(L, N) <- (N is ++len(L))
# Method call on a dereferenced variable
to_upper(S, R) <- (R is ++S.upper())
# Arithmetic
inc(X, R) <- (R is ++(X + 1))
# Subscript access
first(L, R) <- (R is ++L[0])
# Dict access
get_key(D, K, R) <- (R is ++D[K])
# Multiple logic variables
add_len(A, B, R) <- (R is ++(len(A) + len(B)))
# No logic variables (pure Python)
get_pi(R) <- (R is ++(3.14159))
As a goal (side effects):
# Print as a goal
show(X) <- ++print(X)
# Goal followed by continuation
process(X, R) <- (
++print(X),
R is ++(X * 2)
)
Under the hood, ++expr wraps the Python expression in a lambda whose parameters shadow the module-scope Var names. The compiler emits thunk_fn(deref(v0), deref(v1), ...). Any Python expression works — method calls, builtins, arithmetic, subscripts, etc.
Unit-literal sugar — n(Unit)¶
A special case of the ++() pattern: when a numeric literal is used as the
callable with a single unit-predicate argument, it desugars to ++(Unit(n)):
# skip
5(Metre) # → ++(Metre(5)) → Quantity(5, {Metre: 1})
9.8(Newton) # → ++(Newton(9.8)) → Quantity(9.8, {kg:1, m:1, s:-2})
-3(Second) # → Quantity(-3, {Second: 1}) (negation applied after)
When a logic variable is used as the callable instead, X(Unit) becomes a
goal that posts a dimension constraint on X:
# skip
F(Newton) # → HasUnits(F, Newton) — F must be bound to a Newton value
F is 9.8(Newton) # binds F; hook checks dims match
See Physical Units for the full reference.
Compound terms and goals¶
Immediate goals¶
Module qualification¶
Predicates from imported modules are called with dotted notation after loading the module:
See Directives and Import System for details.
Lambdas¶
Lambdas are anonymous clauses — goal closures passed as arguments to higher-order predicates. They use the same head <- body arrow syntax as clause definitions:
# One-arg lambda — X is a parameter, RESULT is captured
apply(RESULT, VAL) <- CallGoal((X <- (RESULT := X + 1)), VAL)
# Two-arg lambda
apply_add(A, B, R) <- CallGoal(((X, Y) <- (R := X + Y)), A, B)
# 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)
# Conjunction body
transform(R) <- CallGoal(((X, Y) <- (T := X + 1, Y := T * 2)), 5, R)
Parameters are lambda arguments; captured variables share the enclosing clause's Var objects. Body-local variables (first appearing inside the lambda) get fresh Var() allocations. Lambdas are called via the CallGoal/1..8 builtins (or Call/1..8).
See lambdas.md for the full design, compilation details, and examples.
Definite Clause Grammars — >>¶
DCG rules provide syntactic sugar for difference-list grammars. Each >> rule compiles to an ordinary <- clause with two extra hidden arguments (input list, remaining list) threaded through the body. This is the same approach as Prolog's -->, using Python's >> operator instead.
Basic syntax¶
# Terminal — consume literal tokens from the input list
greeting >> (["hello", "world"])
# Non-terminal — call another DCG rule (state threaded automatically)
sentence >> (noun_phrase, verb_phrase, noun_phrase)
# Empty terminal (epsilon — matches without consuming)
epsilon >> ([])
Extra arguments and inline goals¶
DCG predicates can have extra arguments beyond the hidden state:
Inline goals are written with {...} (Python set literal syntax). They execute without consuming input — the state passes through unchanged. Multiple consecutive inline goals are optimised to avoid generating unnecessary intermediate state variables.
Conjunction and disjunction¶
# Conjunction — comma-separated (canonical style)
rule >> (a, b, c)
# Disjunction
letter >> (["a"] or ["b"] or ["c"])
Negation¶
Pushback / semicontext¶
The LHS can be a tuple (head, [pushback_tokens]) to push tokens back onto the input after matching:
After the body matches [T], the pushback [T] is prepended to the remainder.
Invoking DCGs with phrase¶
Use phrase/2 or phrase/3 to call DCG rules from regular predicates:
# skip
# phrase/2 — must consume the entire input list
valid_sentence(S) <- phrase(sentence, S)
# phrase/3 — partial parse, remaining input bound to REST
phrase(digit(D), [3, "plus", 4], REST)
phrase/2 passes [] as the expected remainder, so the rule must consume all input. phrase/3 leaves the remainder as a logic variable for partial parsing.
Module exports¶
When using -module(...), DCG predicates must be declared with their full signature including the two hidden state arguments:
# Correct: PredicateMeta classes created with proper field counts
-module(my_grammar, [greeting(S0, S), digit(D, S0, S)])
# Wrong: creates string atom assignments, not predicate classes
-module(my_grammar, [greeting, digit])
How it works¶
The >> rewriting is purely syntactic — it transforms DCG rules into ordinary <- clauses before the compiler sees them:
# This DCG rule:
greeting >> (["hello", "world"])
# Rewrites to this ordinary clause:
greeting(S0, S) <- (S0 is ["hello", "world", *S])
# This DCG rule:
digit(D) >> ([D], {D >= 0}, {D <= 9})
# Rewrites to:
digit(D, S0, S) <- (S0 is [D, *S], D >= 0, D <= 9)
No changes to the compiler, database, or runtime are needed.
DCGs as general state-passing¶
DCGs are not just for parsing — they are a general state-passing mechanism. The difference-list pair can carry any state encoded as a single-element list [State]. Terminals read state, pushback writes it back, and phrase/3 sets initial/final state. (For a good explanation of this pattern, see Markus Triska's DCG tutorial.)
state/1 and state/2 helper nonterminals¶
Two reusable nonterminals form the core of state-passing DCGs:
# skip
# Read current state (passthrough — state is not modified)
(state(S), [S]) >> ([S])
# Read old state S0, replace with S
(state(S0, S), [S]) >> ([S0])
state/1 reads the current state value into S without modifying it. state/2 reads the old state into S0 and writes S as the new state. Copy these into any module that needs state threading.
Counter example¶
Thread a counter through phrase/3:
# Increment: read counter, add 1, write new counter
inc >> (state(N0), {N := N0 + 1}, state(_, N))
# Chain three increments
count3 >> (inc, inc, inc)
Tree leaf counting¶
# Trees as "leaf" or [Left, Right]
count_leaves("leaf") >> (state(N0), {N := N0 + 1}, state(_, N))
count_leaves([L, R]) >> (count_leaves(L), count_leaves(R))
# API: wrap with phrase/3
num_leaves(T, N) <- phrase(count_leaves(T), [0], [N])
Accumulator: collecting items¶
# Push item onto accumulator state
push(X) >> (state(ACC0), {ACC is [X, *ACC0]}, state(_, ACC))
# Push all items from a list
push_all([]) >> ([])
push_all([X, *XS]) >> (push(X), push_all(XS))
Key points¶
- State is encoded as
[Value]— a single-element list.phrase/3sets[InitialState]and receives[FinalState]. - Multiple states → use a compound value:
[state(Count, Items)]or[[Count, Items]]. - State-only DCG (no token parsing): use
phrase/3where the "list" is just a state wrapper. There is no requirement that the threaded state be a token list. - Chaining: DCG nonterminal calls naturally compose —
inc_then_double >> (inc, double)threads the state through both operations sequentially.
Extended DCGs — EDCGs¶
Standard DCGs thread a single state (the difference list). Extended DCGs add support for multiple named accumulators and read-only passed arguments, all threaded automatically through >> rules. This eliminates the boilerplate of manually encoding multiple states into a single compound value.
EDCGs are based on Peter Van Roy's 1989 design and use three directives to declare the threading:
Declaring accumulators¶
An accumulator has a name and a joiner goal that relates a pushed value to the input/output state:
# Numeric counter: Out = In + Value
-edcg_acc(counter, X, IN, OUT, {OUT := IN + X})
# List accumulator: prepend items
-edcg_acc(items, ITEM, IN, OUT, {OUT is [ITEM, *IN]})
# Product accumulator: Out = In * Value
-edcg_acc(product, X, IN, OUT, {OUT := IN * X})
The joiner goal can be any clausal goal wrapped in {braces}. The variable names (X, IN, OUT) are placeholders — they get substituted with actual variables during rewriting.
Declaring passed arguments¶
A passed argument is a read-only value threaded unchanged through all sub-calls:
Declaring predicates¶
Each EDCG predicate must declare its visible arity and which accumulators/passes it uses:
# skip
-edcg_pred(inc, 0, [counter]) # 0 visible args, uses counter
-edcg_pred(process, 1, [counter, items]) # 1 visible arg, uses counter + items
-edcg_pred(parse, 0, [counter, dcg]) # uses counter + standard DCG list
-edcg_pred(scaled_inc, 0, [counter, scale]) # accumulator + passed arg
The special name dcg refers to the standard DCG difference-list accumulator. Include it when your EDCG rule also parses tokens.
EDCG rule syntax¶
EDCG rules use >> just like standard DCGs, with additional operators:
# skip
# Push a value to a named accumulator: [value] // acc_name
inc >> ([1] // counter)
# Read current accumulator value: acc_name / Var
get_and_inc(V) >> (counter / V, [1] // counter)
# Read a passed argument: pass_name / Var
scaled_inc >> (scale / S, [S] // counter)
# Terminal list (requires 'dcg' in the predicate's accumulator list)
token(T) >> ([T], [1] // counter)
# Inline goals don't thread accumulators
inc_if_positive >> (counter / N, {N >= 0}, [1] // counter)
# Sub-calls: accumulators are threaded automatically
count3 >> (inc, inc, inc)
# Empty body: all accumulators pass through unchanged
noop >> ([])
The // operator pushes a value through the accumulator's joiner goal. The / operator reads the current state without modifying it.
Multiple accumulators¶
A single rule can update multiple accumulators simultaneously:
-edcg_acc(counter, X, IN, OUT, {OUT := IN + X})
-edcg_acc(items, ITEM, IN, OUT, {OUT is [ITEM, *IN]})
-edcg_pred(process, 1, [counter, items])
# Each push targets a specific accumulator by name
process(X) >> ([1] // counter, [X] // items)
When a sub-call uses fewer accumulators than the caller, only the shared ones are threaded:
# skip
-edcg_pred(inc_only, 0, [counter]) # only counter
-edcg_pred(do_both, 1, [counter, items]) # counter + items
inc_only >> ([1] // counter)
do_both(X) >> (inc_only, [X] // items) # inc_only threads counter only
Calling EDCG predicates¶
EDCG predicates are compiled to ordinary predicates with hidden arguments appended in declaration order: 2 per accumulator (in, out) + 1 per pass. You can call them from regular <- clauses using keyword syntax:
# skip
# -edcg_pred(count_elems, 1, [len])
# Compiled arity: 1 (visible) + 2 (len_in, len_out) = 3
my_length(L, N) <- count_elems(L, _edcg_len_in_=0, _edcg_len_out_=N)
Or positionally — hidden args follow visible args in the order declared:
Complete example: counter with scale factor¶
-module(example, [run_scaled(LIST, SCALE, COUNT, ITEMS)])
-edcg_acc(counter, X, IN, OUT, {OUT := IN + X})
-edcg_acc(items, ITEM, IN, OUT, {OUT is [ITEM, *IN]})
-edcg_pass(scale)
-edcg_pred(scaled_inc, 0, [counter, scale])
-edcg_pred(collect_and_count, 1, [counter, items, scale])
-edcg_pred(process_list, 1, [counter, items, scale])
scaled_inc >> (scale / S, [S] // counter)
collect_and_count(X) >> (scaled_inc, [X] // items)
process_list([]) >> ([])
process_list([X, *XS]) >> (collect_and_count(X), process_list(XS))
run_scaled(LIST, SCALE, COUNT, ITEMS) <- (
process_list(LIST, _edcg_counter_in_=0, _edcg_counter_out_=COUNT,
_edcg_items_in_=[], _edcg_items_out_=ITEMS,
_edcg_scale_=SCALE)
)
Design notes¶
- Purely syntactic: EDCG
>>rules are rewritten to ordinary<-clauses before compilation. No runtime support needed. - Backward compatible: Rules without
-edcg_preddeclarations continue to use standard DCG rewriting. //for push,/for read: These use Python's floor-division and division operators respectively.- Accumulator order matters: Hidden args are appended in the order listed in
-edcg_pred. When calling from<-clauses, match this order.
Meta-predicates¶
Meta-predicates are higher-order predicates that take goals as arguments. They are compiled as special forms — the goal argument is compiled inline, not passed as a runtime value.
All-solutions predicates¶
# Collect all X where In(X, [1,2,3]) into Bag
FindAll(X, In(X, [1, 2, 3]), BAG),
# Same but with a filter — only X > 1
FindAll(X, (In(X, [1, 2, 3]) and X > 1), BAG),
# Cartesian product — template can be any term
FindAll([X, Y], (In(X, [a, b]) and In(Y, [1, 2])), BAG),
# BagOf fails if no solutions (FindAll succeeds with [])
BagOf(X, In(X, LIST), BAG),
# SetOf deduplicates results (preserving first-occurrence order)
SetOf(X, In(X, [1, 1, 2, 2, 3]), BAG), # BAG = [1, 2, 3]
| Predicate | Empty result |
|---|---|
FindAll/3 |
Succeeds with Bag = [] |
BagOf/3 |
Fails |
SetOf/3 |
Fails |
Universal quantification¶
# Succeeds iff Action holds for every solution of Cond
ForAll(In(X, [2, 4, 6]), X > 0), # succeeds
ForAll(In(X, [2, -1, 6]), X > 0), # fails
ForAll(Cond, Action) is equivalent to not (Cond and not Action).
Call/N¶
Call/N invokes a goal closure with extra arguments. It is an alias for CallGoal/N:
# skip
CallGoal((X <- (X > 0)), 5), # CallGoal/2: succeeds
Call(GOAL, ARG1, ARG2), # Call/3: invoke GOAL with two extra args
Call/1 through Call/8 are available (as are CallGoal/1 through CallGoal/8).
Higher-order list predicates¶
These predicates take a goal closure and apply it across a list. All use committed choice (first solution per element).
# skip
# MapList/2 — check Goal(Elem) succeeds for every element
MapList((X <- (X > 0)), [1, 2, 3]), # succeeds
# MapList/3 — map Goal(X, Y) over list, collect results
MapList(((X, Y) <- (Y := X * 2)), [1, 2, 3], YS), # YS = [2, 4, 6]
# Filter/3 — keep elements where Goal(Elem) succeeds
Filter((X <- (X > 0)), [1, -2, 3, -4], R), # R = [1, 3]
# Exclude/3 — keep elements where Goal(Elem) fails
Exclude((X <- (X > 0)), [1, -2, 3, -4], R), # R = [-2, -4]
# FoldLeft/4 — left fold with Goal(Elem, Acc0, Acc1)
FoldLeft(((E, A, R) <- (R := A + E)), [1, 2, 3], 0, SUM), # SUM = 6
Constraint logic programming¶
Clausal supports CLP(FD) (finite-domain integer constraints) and CLP(B) (Boolean constraints). Constraint operators are used directly in clause bodies — no special escape or domain wrapper is needed.
See Constraints for the full API.
Why not allow free intermingling of Python and logic namespaces?¶
Three main reasons:
-
Ambiguity. It is impossible at compile time to distinguish a Python global from an atom without tracking all imports. Old compiled code could silently become wrong when a new name is imported. With explicit
--escaping, the boundary is always visible. -
Term representation efficiency. Compound terms are most efficiently represented as instances of generated classes (enabling
match/caseto work directly on them). Atoms need to be class objects for structural matching. Allowing arbitrary Python objects as functors requires a boxing wrapper, which is heavier. -
Logic variables must be visually distinct. They are declared implicitly, work differently from Python names, and their bindings are reverted on backtracking. A clear syntactic marker (ALL-CAPS or trailing underscore) avoids confusion without requiring explicit
declarestatements.
The escape mechanisms (--, ++) cover all cases where interop is genuinely needed. Explicit is better than implicit.
Python functions as predicates¶
Any Python function that contains logical terms is treated as a predicate. Python code within the body becomes embedded and backtrackable. An implicit backtracking flag allows cleanup on backtrack:
def head(ARG1, ARG2):
goal(...),
if not backtracking:
# forward path: acquire resource, open file, etc.
...
else:
# backtrack path: release resource, etc.
...
another_goal(...)
This gives a symmetric and concise way to integrate Python side effects with Prolog-style backtracking. The programmer takes responsibility for correctness.
Syntax cheat sheet¶
# skip
# Variables (ALL-CAPS preferred; trailing-underscore also valid)
X, HEAD, REST # ALL-CAPS logic variables
X_, head_, rest_ # trailing-underscore style (also valid)
_ # anonymous variable (always unifies, stores nothing)
# Atoms
Atom, 'an atom', '+' # atoms (titlecase or quoted string)
--python_obj # any Python object used as an atom
# Lists
[] # empty list
[a, 1, X] # simple list
[FIRST, *REST] # head/tail
# Strings
u"hello" # list of character atoms
# Dicts (DictTerm — keys ground, values may be Vars)
{"x": 1, "y": 2} # ground dict
{"x": X, "y": Y} # dict with variable values
{"addr": {"city": C}} # nested dict
# Sets (SetTerm — elements must be ground)
{1, 2, 3} # set of integers
{"red", "green", "blue"} # set of strings
# Unification
X is Y, # unify
X is not Y, # dif constraint (must stay different)
not (X is Y), # immediate check (don't unify right now)
# Arithmetic
(N := X + 1), # evaluate RHS, unify with LHS
# CLP(FD) constraints
X == Y, # arithmetic equality constraint
X != Y, # arithmetic disequality constraint
X < Y, # less-than constraint
X <= Y, # less-or-equal constraint
InDomain(X, 1, 10), # post finite domain
AllDifferent([X,Y,Z]), # pairwise disequality
Label([X, Y, Z]), # enumerate solutions (first-fail)
Equivalent(X, Y), # structural equality
# Rules and facts
Head <- call(X), # single-call body (no parens needed)
Head <- (Body), # operator body (parens required)
Fact, # fact (trivially true)
Rule >> ListDescription, # DCG rule
# Goals
goal(A, B), # compound goal
not goal, # negation as failure
+ goal, # immediate goal
- term, # retract
# Module qualification
utils.Double(X, Y), # qualified call (after -import_module(utils))
# Escaping
--python_expr # Python inside logic term
++logic_term # logic term inside Python expression
~~python_expr # capture as AST node
# Lambdas (anonymous clauses)
CallGoal((X <- (R := X + 1)), 5) # R = 6
CallGoal(((X, Y) <- (R := X + Y)), A, B) # multi-param
# Meta-predicates
FindAll(X, In(X, [1,2,3]), BAG), # BAG = [1,2,3]
BagOf(X, In(X, LIST), BAG), # fails if LIST empty
SetOf(X, In(X, XS), BAG), # deduplicates
ForAll(In(X, NS), X > 0), # universal quantification
Call(GOAL, ARG1), # Call/2 (alias for CallGoal/2)
# F-strings — logic variables auto-deref at search time
Writeln(f"Hello, {NAME}!"), # prints bound value of NAME
Writeln(f"{X:.2f}"), # format specs work
S := f"{X} and {Y}", # capture as string
# DCGs — >> defines grammar rules with difference lists
greeting >> (["hello", "world"]), # terminal sequence
sentence >> (noun_phrase, verb_phrase), # non-terminal chain
digit(D) >> ([D], {D >= 0}, {D <= 9}), # args + inline goals
letter >> (["a"] or ["b"] or ["c"]), # disjunction
not_a >> (not ["a"], [X]), # negation
(peek(T), [T]) >> ([T]), # pushback/semicontext
phrase(greeting, ["hello", "world"]), # phrase/2 — must consume all
phrase(digit(D), [3], REST), # phrase/3 — partial parse
# EDCGs — EXPERIMENTAL (directive parsing only, no end-to-end rewriting yet)
-edcg_acc(counter, X, IN, OUT, {OUT := IN + X}) # declare accumulator
-edcg_pass(config) # declare passed arg
-edcg_pred(inc, 0, [counter]) # declare pred's hidden args
inc >> ([1] // counter) # [value] // acc — push to accumulator
get(V) >> (counter / V) # acc / Var — read current value
scaled >> (scale / S, [S] // counter) # pass / Var — read passed arg
# Python interop — ++() evaluates Python at search time
N is ++len(L), # call Python builtin
R is ++S.upper(), # method call on deref'd var
R is ++(X + 1), # Python arithmetic
R is ++L[0], # subscript access
R is ++D[K], # dict access
++print(X), # side-effect goal
# Higher-order list predicates
MapList(GOAL, [1, 2, 3]), # check GOAL on each element
MapList(GOAL, XS, YS), # map GOAL(X, Y) over list
Filter(GOAL, LIST, KEPT), # keep where GOAL succeeds
Exclude(GOAL, LIST, REMOVED), # keep where GOAL fails
FoldLeft(GOAL, LIST, ACC0, RESULT), # left fold with GOAL(Elem, Acc, Next)
GetItem(INDEX, LIST, ELEM), # 0-based index access
InCheck(ELEM, LIST), # deterministic membership check
Unpack(TERM, LIST), # decompose/construct term