Term Inspection¶
Term inspection predicates let you decompose, construct, copy, and analyze the structure of terms at runtime. They are the foundation for meta-programming — writing predicates that operate on other predicates.
Quick Example¶
# The name position speaks ATOMS. `'hello'` is single-quoted, so it is the
# atom in every -double_quotes mode and needs no -private declaration.
test("decompose atom") <- (
functor('hello', NAME_, ARITY_),
NAME_ is 'hello',
atom(NAME_),
ARITY_ == 0
)
test("unpack atom") <- unpack('hello', ['hello'])
Decomposition & Construction¶
functor/3¶
functor(Term, Name, Arity) — bidirectional. Decompose a term into its functor
name and arity, or construct a term from a name and arity.
Decompose mode (Term bound):
Decompose a compound term — define the predicate first so it is a known term:
point(1, 2, 3),
test("decompose compound") <- (
functor(point(1, 2, 3), NAME_, ARITY_),
NAME_ is 'point',
ARITY_ == 3
)
Construct mode (Term unbound, Name + Arity bound). The result is a
cell — the same term a source-written data functor pair(A, B) compiles
to (a plain tuple):
-implicit_functors
test("construct") <- (
functor(TERM_, 'pair', 2),
TERM_ is pair(A_UNUSED, B_UNUSED)
)
arg/3¶
arg(N, Term, Value) — access the N-th argument of a compound term (1-based).
Define the predicate first so its terms are recognized:
point(10, 20, 30),
test("first arg") <- arg(1, point(10, 20, 30), 10)
test("second arg") <- arg(2, point(10, 20, 30), 20)
test("third arg") <- arg(3, point(10, 20, 30), 30)
Fails if N is out of range. An unbound N or Term is instantiation_error,
an atomic Term type_error(compound, Term) (ISO 8.5.2.3, as Scryer does;
an unbound N used to enumerate the (N, Value) pairs).
unpack/2¶
unpack(Term, List) — the "univ" operator (=.. in Prolog). Converts between a
term and a list [functor | Args].
Decompose mode:
foo(1, 2, 3),
test("unpack") <- unpack(foo(1, 2, 3), ['foo', 1, 2, 3])
test("atom") <- unpack('hello', ['hello'])
Construct mode:
-implicit_functors
test("construct") <- (
unpack(TERM_, ['point', 10, 20]),
TERM_ is point(10, 20), # the same cell a source-written point(10, 20) is
arg(1, TERM_, 10),
arg(2, TERM_, 20)
)
A compound term is always a cell, a plain tuple (functor, *args): the
term unpack/2 or functor/3 builds, a data functor written in source, and
a declared predicate's name used as a term (point(10, 20) above, where
point/2 is a fact) are the same cell and unify. There is no separate class
of "predicate instance".
From Python, read a cell with clausal.cell_functor and clausal.cell_args
(and build one with clausal.make_cell). In a .seam file, the
goal-position seam hands the cell back as it is:
from clausal import cell_functor, cell_args
point(1, 2),
def show():
for T in --unpack(T, ['point', 10, 20]):
print(T) # ('point', 10, 20)
print(cell_functor(T), cell_args(T)) # point (10, 20)
See Python integration for the seam.
Copying¶
copy_term/2¶
copy_term(Original, Copy) — create a deep copy of a term with all unbound
variables replaced by fresh variables. Shared variables remain shared in the
copy.
-allow_singletons
# X_ stands for "some unbound variable" — its identity is never used
# again, only that copy_term/2 gives it a fresh one in COPY_.
test("copy list") <- (
copy_term([1, X_, 3], COPY_),
length(COPY_, 3)
)
Variable Analysis¶
term_variables/2¶
term_variables(Term, Vars) — collect all unbound variables in a term into a
list, in left-to-right order, with duplicates removed (by identity).
-allow_singletons
# X_, Y_, Z_ each stand for "some unbound variable" — the point is that
# term_variables/2 collects three of them, not what they're named.
test("collect vars") <- (
term_variables([X_, 1, Y_, Z_], VARS_),
length(VARS_, 3)
)
test("ground term") <- term_variables([1, 2, 3], [])
numbervars/3¶
numbervars(Term, Start, End) — bind each unbound variable to the cell
'$VAR'(N), numbered sequentially from Start. End is unified with the
next available number.
-allow_singletons
# X_, Y_, Z_ each stand for "some unbound variable" — numbervars/3 binds
# them to $VAR(0..2); their names are never referenced again.
test("number vars") <- (
numbervars([X_, Y_, Z_], 0, END_),
END_ == 3
)
This is useful for displaying terms with readable variable names.
gensym/2¶
gensym(Prefix, Atom) — generate a unique atom by appending a monotonically
increasing counter to Prefix.
gensym('x', A1), # A1 = x_1
gensym('x', A2), # A2 = x_2
gensym('y', A3) # A3 = y_1 (independent counter)
The counter is impure — it does not reset on backtracking. This matches
Prolog's gensym/2 semantics and is useful for generating fresh names in
meta-programming or code generation.
Fields by Name¶
A term's fields can also be addressed by NAME. The names come from the functor's declaration:
vary/3 copies a term with some fields replaced, unbound_keys/2 lists the
fields that are still unbound, and signature/3 reflects the declared field
names of a functor.
The keyword CONSTRUCTION spelling was retired on 2026-09-19
A term used to be writable as point(x=1, y=2, z=3), and the first clause
written that way was what named the fields. A term is built positionally
now, and a keyword argument in a term or a clause head is a load-time
error. The spelling had no ISO Prolog reading, and it made a functor's
field names depend on which of its clauses came first.
Two keyword spellings are unaffected: a -directive's options
(-specialize(solve, p, alias=q)) and an EDCG hidden argument
(p(L, _edcg_counter_in=0)).
The KWTerm class and the extend/3 builtin (which grew a term by new
keyword fields) went with it: a compound term is a plain cell, and its
fields are fixed by its declaration.
A term is never padded: a construction with fewer arguments than the declared functor has fields is refused, not filled with fresh logic variables. A field you want to leave open is written as a variable -- which is what a partial term is:
-private([point(x, y, z)])
p(P) <- (P is point(10, _Y, _Z)) # point(10, _, _)
p(P) <- (P is point(_X, 20, 30))
vary/3¶
vary(Overrides, Term, NewTerm) — copy a term, replacing specified fields with
new values. Overrides is a Python dict mapping field names to new values.
test("change one field") <- (
vary({"x": 100}, point(1, 2, 3), R),
R == point(100, 2, 3)
) # nv
test("change multiple") <- (
vary({"x": 10, "z": 30}, point(1, 2, 3), R),
R == point(10, 2, 30)
) # nv
Works for declared functor cells and term (dataclass) instances.
unbound_keys/2¶
unbound_keys(Term, Keys) — list the field names that are still unbound
(contain logic variables).
# A field name is an ATOM (spec §6.4), so the keys are `y`/`z`, not the
# strings "y"/"z".
test("unbound") <- (
unbound_keys(point(1, Y_UNUSED, Z_UNUSED), KEYS),
in_('y', KEYS),
in_('z', KEYS),
length(KEYS, 2)
) # nv
test("fully bound") <- unbound_keys(point(1, 2, 3), []) # nv
signature/3¶
signature(FunctorName, Arity, Names) — reflect the registered signature of a
predicate. Given a functor name and arity, unifies Names with the tuple of
field names.
# The name position speaks atoms in AND out (spec §6.4).
test("signature") <- (
signature('point', 3, NAMES),
NAMES == ['x', 'y', 'z']
) # nv
This is a database-dependent operation — the predicate must have been defined
(with a signature) before signature is called.
Recipes: fields by name¶
Default values via vary:
Inspect which fields need filling:
Reflect on predicate structure:
describe_predicate(NAME, ARITY) <- (
signature(NAME, ARITY, FIELD_NAMES),
writeln_text(f"Predicate {NAME}/{ARITY}"),
writeln_text(f"Fields: {FIELD_NAMES}")
)
Gotchas: fields by name¶
- A term cannot grow new fields — a functor's fields are the ones it is
declared with. Use
varyto change existing fields. signaturerequires the predicate to be registered — if you call it before the predicate is defined (e.g., in a different module that hasn't been imported), it will fail.- Field names are atoms —
unbound_keysandsignatureanswer atoms (['x', 'y', 'z']). An override dict's keys may be quoted atoms ({'x': 10}) or strings ({"x": 10}); a bare{x: 10}key must be a declared atom, like any bare atom. - Field names come from the declaration —
-private([point(x, y, z)])or the-moduleexport list. An undeclared predicate's fields arearg_0,arg_1, … , whichvaryandunbound_keyswill happily use but nobody wants to read.
Patterns & Recipes¶
Generic term transformer¶
Transform all arguments of any term by applying a goal (using maplist):
point(1, 2, 3),
map_args(GOAL_, TERM_, RESULT_) <- (
unpack(TERM_, [FUNCTOR_, *ARGS_]),
maplist(GOAL_, ARGS_, NEW_ARGS_),
unpack(RESULT_, [FUNCTOR_, *NEW_ARGS_])
)
Count variables in a term¶
-allow_singletons
var_count(TERM_, N_) <- (term_variables(TERM_, VARS_), length(VARS_, N_))
# X_, Y_, Z_ each stand for "some unbound variable" fed into var_count/2.
test("count") <- var_count([X_, 1, Y_, Z_], 3)
Clone a predicate call with different arguments¶
edge('a', 'b'),
rewrite_first_arg(TERM_, NEW_ARG_, RESULT_) <- (
unpack(TERM_, [F_, _, *REST_]),
unpack(RESULT_, [F_, NEW_ARG_, *REST_])
)
Gotchas¶
argis 1-based —arg(1, ...)is the first argument, notarg(0, ...).copy_termpreserves sharing — if the same variable appears twice in the original, the copy will have the same fresh variable in both positions.numbervarsmutates the term — it binds variables in place. Usecopy_termfirst if you need the original term unchanged.unpackconstructs CELLS — when building from a list, the result is a plain tuple('point', 10, 20). Python code reads it withclausal.cell_functor/clausal.cell_args.functor/3in construct mode does the same.- The name position is an atom —
functor/3andunpack/2hand back an atom for the name and require one to build with; a string there raisestype_error(atom, …)(ortype_error(atomic, …)for the 1-element case). A list and a string both decompose as the'.'/2structure they denote; seefunctor/3for the full table.
See also: Type Checking — test term types without decomposition, Meta-Predicates — findall, bagof for collecting solutions, Predicates — defining predicate structures, Dicts & Sets — DictTerm for general key-value data.