Importing Prolog Code¶
Experimental in 1.0
.pl import is experimental and outside the 1.0 compatibility
promise (see Public API). It translates Prolog source
into seam syntax with an older translator; it is not an ISO
Prolog system. This loader does not run cut or if-then-else yet: programs
that use them are refused (see Known limitations).
For running real ISO Prolog alongside Clausal Prolog, use the
Scryer or Trealla embeddings.
Clausal Prolog can import .pl (Prolog) files directly. drop a .pl file on
sys.path and import it — the engine translates, compiles, and caches it
automatically.
import clausal # installs the import hook
import my_prolog_module # translates my_prolog_module.pl on the fly
The result is a normal predicate module: each predicate is bound to its handle,
dispatch is compiled, and everything works exactly as if you had written the
code in seam (.seam) syntax — including querying it from Python with a goal cell
and module=.
Quick example¶
Given a file edges.pl:
Import and query it from a .seam file like any predicate module:
# app.seam
-module(app, [])
-import_from(edges, [path])
def main():
for X in --path(1, X):
print("path", 1, X) # path 1 2, path 1 3, path 1 4
From a plain .py file, where -- is not available, use the lower-level
solve with a goal cell:
import clausal
from clausal import solve, Var
from clausal.logic.variables import deref
import edges # finds and translates edges.pl
x, y = Var(), Var()
for _ in solve(("path", x, y), module=edges):
print(f"path({deref(x)}, {deref(y)})")
Output:
How it works¶
The translation pipeline runs inside Python's import machinery:
.pl source
→ prolog_to_clausal() # Prolog text → seam text
→ EmbedTransformer # seam text → Python AST
→ compile() # Python AST → bytecode
→ .pyc cache # bytecode cached in __pycache__/
On the first import, the full pipeline runs. On subsequent imports, the cached
.pyc is loaded directly — no translation or parsing.
Finder priority¶
Clausal Prolog registers two import finders ahead of Python's own, checked in this order:
| Priority | Finder | Extensions | Loader |
|---|---|---|---|
| 1 | PredicateFinder |
.seam, then .clausal, then .pl |
.seam via PredicateLoader; .clausal always via NativePrologLoader; .pl via PrologLoader or NativePrologLoader (CLAUSAL_PL_FRONTEND) |
| 2 | ModulesFinder |
(py.X names) |
redirects to clausal.modules.py.* |
PredicateFinder resolves per sys.path entry, in path order, as Python
does: the first entry that holds the module wins, whatever its extension.
Only within one entry does the extension decide: a flat foo.seam, then a
foo/__init__.seam package, then a flat foo.clausal, then a flat foo.pl,
then a foo/__init__.clausal or foo/__init__.pl package. So if both
foo.clausal and foo.pl exist in the same directory, the .clausal file
wins -- you can keep the original .pl alongside a hand-converted
.clausal (or .seam) version and the right one is always loaded -- but a
foo.pl in an earlier sys.path entry beats a foo.clausal in a later one.
A plain Python module is found by Python's PathFinder, which runs after
these finders: a .seam, .clausal or .pl module anywhere on sys.path
still wins over a .py module of the same name in an earlier entry.
Which way imports may go¶
The dependency between the two Prolog surfaces is one-way. A .pl module
may import a Clausal Prolog module or a seam (.seam) module. A Clausal
Prolog module may not import a .pl module, because ISO Prolog may use
cut: such a use_module is refused at load with
permission_error(access, prolog_module, M). Convert the Prolog code to
Clausal Prolog, or wrap it in a .seam module, and import that. When a
.seam or Clausal Prolog file sits beside the .pl of the same name, the
finders pick it and the import is allowed. Seam modules may still import
.pl modules (the rest of this page): .seam is the Python boundary and
the rule does not bind it.
No run-time route either¶
The rule is not only about use_module. A Clausal Prolog clause may not
reach a .pl module at run time by any route; each raises the same ISO
error, which catch/3 can match:
route (from a .clausal clause) |
example |
|---|---|
| a qualified goal written in the clause | q(X) :- legacy:p(X). |
call/N of a qualified goal or closure built at run time |
G = legacy:p(X), call(G), call(legacy:p, X), maplist(legacy:p, L) |
| assert/retract into the module | assertz(legacy:f(a)), retract(legacy:f(_)), retractall(legacy:f(_)) |
| reading its clauses | clause(legacy:f(X), B) |
| a nonterminal | phrase(legacy:greeting, L) |
The module that ANSWERS decides: in m1:m2:G that is m2, as in ISO. A
Python module named the same way (json:dumps(...)) is refused with
permission_error(access, python_module, M): Clausal Prolog reaches Python
only through a .seam module. A seam or .pl caller never pays for the
check, and Python code calling solve() is not checked (Python is the
programmer's responsibility).
Closures from .pl (the one place the rule bites the allowed
direction). A .pl module may call a Clausal Prolog meta-predicate, but a
-meta_predicate argument is qualified with the CALLER's module, so the
closure arrives as pl_module:G. Running it from the Clausal Prolog frame
is a call into .pl, and is refused (ruled: strict for closures too):
% kit.clausal
:- module(kit, [app/1, hello/0]).
:- meta_predicate(app(0)).
app(G) :- call(G).
hello.
:- end_module(kit).
% legacy.pl
:- module(legacy, [mine/0, theirs/0]).
:- use_module(kit, [app/1]).
own.
mine :- app(own). % permission_error(access, prolog_module, legacy)
theirs :- app(kit:hello). % fine: the closure names Clausal Prolog code
Pass the .pl code a closure into Clausal Prolog (or .seam) code
instead, or convert the predicate the closure names.
Importing data names from a .pl module¶
This section is about SEAM (.seam) importers and .pl importers. A
Clausal Prolog (.clausal) module may not import a .pl module at all
(above).
A .pl module's export list holds only name/arity predicates, and data
needs no declaration. So a seam -import_from of a name that the .pl
module neither defines as a predicate (at any arity) nor binds gives you
the ATOM of that name:
% citations.pl
:- module(citations, [citation/2]).
citation(art1, [label-'Art 1']).
% rules.pl
:- module(rules, [verdict/2]).
verdict(P, v(ok, [cite(art1)])) :- P = p.
# score.seam
-import_from(citations, [citation, cite]) # citation: the predicate
# cite: the atom cite
-import_from(rules, [verdict, v, ok, p])
match(K, C) <- (verdict(p, T), T is v(ok, [cite(K)]), C is cite(K))
The query match(K, C) answers K = art1, C = cite(art1).
An atom is global by spelling, so the cite you import is the same atom
the .pl rulebase writes in cite(art1): the term built from it
(('cite', 'art1')) is == to the rulebase's.
- A name the module binds keeps its ordinary meaning: an exported predicate imports the predicate, and a data atom the file itself uses imports that atom. A predicate the module defines but does not export is an error (see Unexported predicates in Clausal code).
- A
name/Nentry names a predicate, so it never resolves to data: with noname/Nin the module it is still anImportError. - The imported name also builds terms at any arity, positionally:
--cite(++k)and a clause'scite(K)are('cite', K), the rulebase'scite(k). So does a data functor the.plmodule uses and you import (vabove), under either front end. - A typo can no longer fail the import, so a name within a small edit
distance of one of the module's predicates (
citatonforcitation) warns once, withClausalImportedDataNameWarning, naming the predicate. - Only
.pltargets: a.seammodule exports its data in its-module(...)list, and a name missing there is still anImportError.
Reading a data name as an attribute¶
The same rule answers attribute access from Python (ruled 2026-10-01).
getattr(mod, 'employment') or mod.employment on a module loaded from
.pl (either front end, including a package's __init__.pl) is the atom
'employment' when the module neither defines employment as a predicate
(at any arity) nor binds it:
import citations # citations.pl
citations.cite # 'cite', the atom
citations.citation # the predicate's handle, as before
- Only an atom-shaped name is answered: a lowercase identifier that is no
Python keyword and no reserved name (
true,false,undefined). A dunder or private name (__wrapped__,_x), a TitleCase name and a non-identifier still raiseAttributeError. - Tooling gets no answer: importlib's submodule probe, the Python standard
library (
unittest'sload_tests,doctest,pickle,inspect) and tools such as pytest and Sphinx still seeAttributeError, so their hook lookups (getattr(mod, 'load_tests', None)) behave as before. Only your own code gets the atom. - A submodule of a
.plpackage that is not imported yet is not data:from pkg import substill imports it. - In Python code,
from mod import nameisgetattr(mod, 'name'), so an unbound atom-shaped name imports its atom too (ruled 2026-10-01: in Python code, Python semantics apply). That includes a name that is not a submodule of a.plpackage:from pkg import nosuchsubgives the atom'nosuchsub'. In seam code the same name goes through the seam-import_fromrules above. - A near miss of a predicate (
citations.citaton) warns once per module and name, withClausalImportedDataNameWarning. .seamand Python modules are unchanged.
getattr/hasattr on a .pl module no longer signals absence; use
clausal.has_predicate / clausal.defines_predicate /
clausal.module_binds. clausal.has_predicate(mod, name, arity=None)
asks whether name is a predicate you can call through the module (defined
there or imported, as in a thin facade), clausal.defines_predicate whether
the module itself defines it, and clausal.module_binds(mod, name) whether
the name is a real attribute of the module (a predicate or data).
Unexported predicates from Python¶
In Python code there is no export privacy (ruled 2026-10-01: in Python
code, Python semantics apply). A predicate a .pl module defines but does
not list in its module/2 export list is still an attribute of the module,
so Python reaches it like any module attribute:
import citations # :- module(citations, [citation/2]).
citations.helper # helper/1 is not exported: its handle
from citations import helper # the same handle
solve(("helper", X := Var()), module=citations) # runs
Possible, but not supported long-term and not advisable
Reaching an unexported predicate from Python works today, under both front ends, but it is not supported long-term and may stop working in a future release. Python callers should use the module's exported predicates. There is no runtime warning.
Clausal code gets no such access: a seam -import_from(citations, [helper])
(or helper/1, or alias(helper, h)) and a .pl
:- use_module(citations, [helper/1]) of a predicate the module defines
but does not export are load-time errors (see
Unexported predicates in Clausal code).
Importing between .pl files¶
Prolog's :- use_module directive is translated to the seam's import system.
when one .pl file imports another, the import hook handles both files:
% helpers.pl
double(X, Y) :- Y is X * 2.
% main.pl
:- use_module(helpers, [double/2]).
quad(X, Y) :- double(X, T), double(T, Y).
The use_module with an explicit import list is the recommended form — it
maps directly to the seam's -import_from directive, which injects the
imported predicates into the calling module's namespace. The list keeps its
indicators, so [double/2] imports double/2 only, as in Scryer; a
library(...) list is imported by bare name (a library may be a Python
module, which has no arities). use_module/1
imports every predicate the .pl module's module/2 directive exports
(-import_module plus an -import_from of that list); for a .seam module
it is -import_module, whose predicates are reached
qualified.
An EMPTY import list, :- use_module(m, [])., is a translation error naming
the line: Scryer reads it as remove_module/2 (it drops m's imports and
does not load m, so m:p(X) is an existence_error), while Trealla and
SWI load m and import nothing. Write use_module(m) or
use_module(m, [p/1]) instead; after either, m:p(X) reaches every
predicate m exports, in Clausal Prolog, Scryer and Trealla alike.
Unexported predicates in Clausal code¶
"Clausal code" here is a seam (.seam) or .pl importer: a Clausal
Prolog (.clausal) module may not import a .pl module at all, exported
predicate or not (above).
In Clausal code, importing a predicate that a .pl module defines but does
not list in its module/2 export list is a load-time ImportError carrying
permission_error(access, private_procedure, Name/Arity) (ruled
2026-10-01). That covers a seam -import_from(m, [p]), [p/N] and
[alias(p, q)], and a .pl :- use_module(m, [p/N]), under both front
ends:
-import_from(m, [helper]) # ImportError: ... permission_error(access,
# private_procedure, helper/1) -- m defines
# helper/1 but does not export it
- A bare name is refused when the module exports it at no arity; a
p/Nentry when the module definesp/Nand does not export it. A bare name exported at some arities imports only those: withp/1exported andp/2defined but not exported,-import_from(m, [p])(oruse_module(m, [p])) is-import_from(m, [p/1]), so a callp(X, Y)through it raisesexistence_error(procedure, p/2)and the importer may define its ownp/2. - A circular import is not checked (the exporter is still loading).
- A
.plfile with nomodule/2directive exports everything. - A name the module does not define as a predicate is unaffected: a data name still imports its atom (above).
- Scryer accepts
use_module(m, [helper/1])for an unexportedhelper/1silently and imports nothing, so the later call raisesexistence_error(procedure, helper/1); Clausal Prolog refuses at load time instead. The error term is provisional. - Python code is not affected (see Unexported predicates from Python).
end_module: closing a module¶
A .pl module file may end with the ISO 13211-2 directive
:- end_module(Name). Both .pl front ends check it:
Namenames the module the file's:- module(Name, Exports).opened;- it is the last item of the file: only comments may follow it.
Anything else is a load-time SyntaxError naming the line and carrying the
ISO error term (the ISO 13211-2 text is not in this repository; the terms
are Clausal Prolog's, in Scryer's error(E, Context) shape):
| The file | The error |
|---|---|
:- end_module(other). in module m |
error(existence_error(module, other), end_module/1) |
end_module with no module/2, or a second end_module |
error(existence_error(module, Name), end_module/1) |
a clause or directive after end_module(m) |
error(permission_error(modify, module, m), end_module/1) |
end_module(X) / end_module(f(x)) |
instantiation_error / type_error(atom, f(x)) |
Requiring it. In a .pl file end_module is optional. When it is
REQUIRED, a module file without it fails to load with
error(existence_error(directive, end_module(m)), load/1), naming the file
and the module. Most specific first:
- the file's own
:- set_prolog_flag(require_end_module, true).(orfalse) -- it governs that file only; - the process-wide setting:
CLAUSAL_REQUIRE_END_MODULE=1(or0) in the environment,clausal.end_module.set_require_end_module(True | False | None)from Python, orset_prolog_flag(require_end_module, V)run as a goal (Vistrue,falseordefault);current_prolog_flag/2reads it; - the surface's default:
.pldoes not require it; Clausal Prolog (.clausal) does.
A seam (.seam) file is never affected, and has no
end_module.
Scryer does not accept end_module/1 (domain_error(directive,
end_module/1), and the file fails to load): a file meant for Scryer as well
leaves it out, or has it removed on the way
(clausal.end_module.strip_end_module).
Importing a Python-backed module¶
Under the native front end (CLAUSAL_PL_FRONTEND=native) a .pl file can
import an engine module whose predicates are written in Python, such as
py.datetime:
:- use_module(py/datetime, [date_add/3, timedelta/3, days_between/3]).
due(D) :- timedelta(30, 0, TD), date_add(date(2026, 1, 15), TD, D).
py/X names the same module as -import_from(py.X, ...) in a .seam
file. Each name/N entry is checked against the module's predicates (see
clausal.module_signatures in the public API): a name the
module does not have, or an arity it does not register, is a load-time
SyntaxError that names the line and lists what the module offers.
use_module(py/datetime) with no list imports every predicate the module
has. A Python predicate is one object for all its arities, so importing
p/N also makes p's other registered arities callable. A bare atom in
the list imports nothing, as for any module.
Python libraries from Clausal Prolog: library(...) facades¶
Clausal Prolog reaches Python only through a .seam module. Every
engine Python module has a generated .seam facade, which Clausal Prolog
imports as a system library, the Scryer way:
:- use_module(library(datetime), [date_add/3, timedelta/3, days_between/3]).
:- use_module(library(countries/european_union), [euro, eur_cent]).
:- use_module(library(units), [second]).
| Python module | Clausal Prolog import |
|---|---|
clausal/modules/py/<lib>.py (py/datetime, py/json, py/re, ...) |
library(<lib>) |
py/os, py/files, py/random, py/uuid, py/csv, py/process |
library(py_<lib>) (library(py_os), ...) |
clausal/modules/<m>.py (units, imperial, currency, graphs, reflection) |
library(<m>) |
clausal/modules/countries/<j>.py (european_union, ...) |
library(countries/<j>) |
A facade (clausal/library/<path>.seam) is a pure re-export: the module's
predicates with the same names and arities, and its values (units,
currencies, numeric constants), the very same objects. So a call site
changes only its import line. A library the front end already knows
(clpz, lists, reif, ...) is never shadowed by a facade, and no facade
takes the name of one of Scryer's own libraries: the six adapters whose
names Scryer already uses (os, files, random, uuid, csv,
process) are library(py_<lib>), so library(os) still means Scryer's
library, which Clausal Prolog does not provide (an error, as before). The list of
facades is clausal._py_facades.PY_FACADE_LIBS, readable without importing
the engine; python -m clausal.tools.gen_library_facades regenerates the
facades (--census prints the module -> facade table).
In a Clausal Prolog module, an import whose target is a Python module by path is refused at load time, naming the facade:
use_module(py/datetime, [...]): permission_error(access, python_module,
py.datetime) -- Clausal Prolog reaches Python only through a .seam module;
import the facade library(datetime) instead
A module name that the seam resolves through its aliases
(european_union, units, date_time) is no Python path: in Clausal
Prolog it imports that module's facade, so
:- use_module(european_union, [euro]). keeps working. A Python module that
has no facade (one of your own) needs a .seam wrapper written for it, and
that wrapper is a Python bridge your project must allowlist (next
section).
Python bridges: which .seam modules Clausal Prolog may import¶
A .seam module can host Python (++ escapes, -- seams, module-level
import/def/class/statements, Python in f-string slots, imports of
Python modules the engine does not ship). From a Clausal Prolog importer,
Python is reachable only through:
- Engine-shipped modules: the
library(...)facades, the engine stdlib, the engine's own py adapters (py.datetime, also spelled by its seam aliasdate_time), and every other file of the engine. "Engine-shipped" is decided by the resolved FILE: for an installed engine, the file is listed in theclausaldistribution's own RECORD; for an editable install or a source checkout, it lies under the engine's package directory (the directory ofclausal/__init__.py, when that is not inside asite-packagestree). When neither can be established, nothing counts as engine-shipped (fail closed). Not by the name: an optionalclausal-*package splices its adapters into the same namespace (clausal.modules.py.scipy_stats), and those are not the engine's -- a.seamimporting one is a Python bridge. A.seammodule whose only Python contact is importing engine-shipped adapters is Python-free (case 2). This is the default mode, which trusts every engine-shipped adapter; a sandbox mode narrows the engine adapters to its own allowlist. (A Clausal Prolog file's DIRECTuse_module(py/X)stays refused as above: it imports the facade.) - A
.seammodule with no Python in it: logic code in seam syntax, checked at load. It is a pass-through: the modules it imports (-import_from,-import_module, the qualifier of a dotted callm.p(...)) are checked the same way, transitively, and must themselves be Python-free, engine-shipped, or allowlisted. "Python-free" is an ALLOW-LIST: every name the module reaches outside itself must resolve positively to a DECLARED EXPORT. Each name of an-import_from(M, [...]), aliased or not, must be in M's export list (a Clausal Prolog module'smodule/2list; for an engine Python module, its predicates asclausal.module_signatureslists them plus its values -- units, currencies, numbers -- exactly what itslibrary(...)facade re-exports). A dotted chain must be exactlymodule.exportormodule.export(...); anything deeper (py.csv.io.open(...)), an attribute that is no export (py.files.pathlib), or any attribute of an imported name (zz.Pathafteralias(pathlib, zz)) is a route, and so is anything that does not resolve (fail closed). A term constructor such aspy.datetime'sdate/3is no export: declare the term instead (-private([date(y, m, d)])).
The decider is an audit of the module's final generated Python
(clausal.seam_audit): the exact tree the import hook compiles, produced
by the same function (import_hook.transform_seam_source), nothing
executed. Every node must be on an allow-list: literals and containers;
the engine's own $ names (from the compiler's tables,
import_hook.runtime_builtins and PER_MODULE_RUNTIME_NAMES; their
arguments are audited too); names the module itself binds; imports of a
Clausal Prolog or engine-shipped module, each name a declared export; clause
references that are exactly module.export; and the compiler's fixed
plumbing statements, matched by the shape its own builders emit.
Anything else -- a call of anything but an engine helper, an attribute
not rooted at an engine name, an underscore-led attribute, a name the
module does not bind (exec, open, __import__), a def, class or
comprehension -- is a route. The compiler's record of what it resolved
(clausal.python_bridges.compiler_record(path)) stays as diagnostics:
it names the construct the author wrote, and its routes are only ever
added. In the compiler itself, an underscore-led part of a qualified
name (si_area.__class__, units._x) is a load error in every seam
module: a qualified name is module.name, and a Python attribute is
reached with a ++ escape.
3. A .seam module with Python that the importer's project allowlists
in its pyproject.toml:
[tool.clausal]
python_bridges = [
"myapp.bridges.osinfo", # a dotted module name
"bridges/net.seam", # a path, relative to this file
{ module = "myapp.bridges.db", sha256 = "9f2c...64 hex digits" },
{ path = "bridges/fs.seam", sha256 = "..." },
]
Anything else is refused at load time:
use_module(helper, [...]): permission_error(import, python_bridge, helper)
-- helper (/proj/helper.seam) runs Python (python_import at line 1, escape
at line 3); Clausal Prolog reaches Python only through the engine's
library(...) facades, a .seam module with no Python in it, or a Python
bridge its project allowlists. To trust it, list it in /proj/pyproject.toml:
[tool.clausal]
python_bridges = ["helper"]
or pin its content: { module = "helper", sha256 = "a7f4..." }
The rules:
- Which
pyproject.toml: the nearest one walking up from the importing.clausalfile (the first found is the project, with or without a[tool.clausal]table). The importer's project decides what it trusts; the imported module never vouches for itself, and the working directory andsys.argvplay no part -- a program that loads another repository's.clausalfiles gets THAT repository's allowlist. - Entries: a string containing
/or ending in.seamis a path (relative to thepyproject.toml); any other string is the dotted module name the import resolves to. A table takes exactly one ofmoduleorpath, and optionallysha256(of the file's bytes). - A sha pin that does not match is refused, naming both hashes:
... is allowlisted in /proj/pyproject.toml pinned to sha256 <pinned>, but the file's sha256 is <actual>: it changed since it was pinned. - A malformed allowlist (not a list, an unknown key, a bad sha, TOML that does not parse) refuses every bridge it was asked about, saying why.
- Transitivity: an allowlisted bridge is trusted Python; its own imports are its business and are not walked. A Python-free module is a pass-through, so the Python module behind it must be allowlisted (the refusal names it and the chain that reached it).
- Load order does not matter: the decision is made from the FILE on
every load of the importer (including a bytecode-cache hit), so a bridge
some
.seamimporter loaded first is refused just the same. A qualified callhelper:cwd(D)(orcall/Nof one) from Clausal Prolog into a bridge some other code loaded raises the same error term at run time,error(permission_error(import, python_bridge, helper), _), whichcatch/3can match. .seamand.plimporters are not affected:.seamis the Python boundary and the programmer's responsibility.
The detector is clausal.python_bridges.python_routes(tree, source), which
returns (kind, line) pairs for a parsed seam module (kinds:
clausal.python_bridges.ROUTE_KINDS); file_python_routes(path) adds the
routes that need resolving: py_adapter (an import or dotted call of an
adapter the engine does not ship) and python_module (any other Python
module the engine does not ship).
The refusal applies to Clausal Prolog (.clausal, the surface
clausal._suffixes.CLAUSAL_PROLOG_SUFFIXES names since the extension
flip). A .pl file (the ISO surface) and a seam file are unaffected: a .pl may still import py/datetime directly, and
may use the library(...) spelling too.
Module paths¶
A module is named by an atom (helpers), a quoted or unquoted path
('sub/helpers', sub/helpers, '../shared/helpers', with or without a
.pl suffix), or library(Name). As in Scryer, a path is resolved against
the importing file's own directory first; the file found there is
imported under its dotted module name (relative to the importer's package
root, or else to a sys.path entry). A path with no such file beside the
importer is read as a dotted module on sys.path (a/b is a.b), which is
how a bare name has always been resolved.
A module spec that names no importable module — a variable, a compound that
is not an a/b path, '../../x' with no such file and no dotted reading, or
a file outside every sys.path entry — is a load-time SyntaxError that
names the directive and its line. Until 2026-09-29 an unquoted a/b path
became a comment and the import vanished, and a quoted one failed with
"argument must be a dotted module path".
Name clashes: qualified calls¶
When two modules export the same name, import them without a list (or
without that name) and call each one module-qualified, as in ISO and
Scryer: m:p(X) crosses as the seam's qualified call m.p(X).
In a Clausal Prolog (.clausal) file, m may not be a .pl module: the
call raises permission_error(access, prolog_module, m) when it runs
(above).
:- use_module(small_sizes).
:- use_module(big_sizes).
both(A, B) :- small_sizes:size(x, A), big_sizes:size(x, B).
Renaming an import with as (use_module(m, [p/2 as q])) is not
accepted: neither ISO nor Scryer has it, and the reader refuses the
directive. (A .seam file has its own rename,
alias(p, q).)
Library imports¶
Standard Prolog library imports are mapped to built-in modules:
| Prolog | Seam equivalent |
|---|---|
:- use_module(library(clpfd), [...]) |
-import_from(clausal.logic.clpfd, [...]) |
:- use_module(library(clpz), [...]) |
-import_from(clausal.logic.clpfd, [...]) |
:- use_module(library(clpb), [...]) |
-import_from(clausal.logic.clpb, [...]) |
:- use_module(library(tabling), [...]) |
-import_from(clausal.logic.tabling, [...]) |
:- use_module(library(lists)) |
(built-in — no import needed) |
:- use_module(library(apply)) |
(built-in — no import needed) |
:- use_module(library(L)), L one of dif, between, error, pairs, when, freeze, iso_ext, dcgs |
(built-in — no import needed) |
:- use_module(library(L), [...]), every listed name an engine builtin |
(built-in — no import needed) |
Any other library(Name) is read as the module Name (a missing one is an
ImportError at load). A predicate of a built-in library that the engine
lacks raises the ISO existence_error when it is called.
Constants, units and dicts (native front end)¶
With CLAUSAL_PL_FRONTEND=native, a .pl file declares constants and units
with the same directive family as the seam
(Constants Directive), in ISO syntax:
:- use_module(european_union, [euro, eur_cent]). % a unit is a Python value
:- constant_value(max_retries, 3).
:- constant_number_units(max_fine, 5000, euro).
:- constant_number_units(one_euro, 1, euro).
:- constant_number_currency(fee, "292.00", euro). % exact decimal: a string
:- constants_number_currency(snap_max/2, [[1, 29200], [2, 53600]],
eur_cent, money_at(2)).
next_try(X) :- X is constant(max_retries) + 1.
fine(N, Q) :- Q is N * constant(one_euro), Q > constant(max_fine).
declared(N, U) :- constant_number_units(max_fine, N, U). % 5000, euro
value(V) :- constant_value(max_fine, V). % program-wide
constant(Name)is replaced at compile time (term expansion), as in the seam; it is not an evaluable functor. The name must be declared above the clause (or imported); anything else, includingconstant(X)orconstant(f(a)), is a load error naming the.plline.- Units come only from these declarations.
5*eurois the ordinary ISO term'*'(5, euro);make_quantity/3stays available. Comparing a quantity with a plain number or another unit raisessystem_error(units_mismatch), as in the seam; units that cancel give a plain number. use_module(M, [name])on a Python module (a currency jurisdiction such aseuropean_union) imports the valuename, as-import_fromdoes; on a Prolog module a bare name the module exports imports exactly its exported arities ([p]is[p/1]whenp/1is the export), and a name it does not export imports nothing (D11).- A value is ISO data:
:- constant_value(k, 2*3).holds the term'*'(2, 3)(whichis/2evaluates to 6), an atom is that atom, and"..."follows thedouble_quotesflag in force (chars by default). Under a units directive the number is a number or a double-quoted decimal string ("292.00", exact), never an atom. - Dicts are predicate forms only (no literal, no subscript):
dict_pairs/2builds,get/3reads softly (fails on a missing key),get_strict/3reads strictly (existence_error(dict_key, Key)),dict_put/4anddict_put_pairs/3update.
DCG rules (native front end)¶
With CLAUSAL_PL_FRONTEND=native (the front end of Clausal Prolog), a
--> rule is translated to the clause ISO 7.14 and Scryer's
library(dcgs) give it, then loaded as that clause. A nonterminal
name//N is the predicate name/(N+2), the two list states last, and
phrase/2,3 call it:
:- module(greet, [greeting//0, word//1]).
:- use_module(library(dcgs)). % optional; brings the `|` operator
greeting --> [hello], word(_).
word(W) --> [W].
digits([D|T]) --> digit(D), digits(T).
digits([D]) --> digit(D).
digit(D) --> [D], { member(D, "0123456789") }.
look(X), [X] --> [X]. % pushback: X is left in the input
| Grammar body | Clause body (between S0 and S) |
|---|---|
[T1, ..., Tn] |
S0 = [T1, ..., Tn \| S] |
[] |
S0 = S |
"abc" |
the terminals [a, b, c] under double_quotes chars (the default), the codes under codes; under atom, the nonterminal abc//0 |
A, B |
A(S0, S1), B(S1, S) |
A ; B, A \| B |
A(S0, S) ; B(S0, S) |
{G} |
G, S0 = S |
call(G, Args...) |
call(G, Args..., S0, S) (call//N) |
a variable B |
phrase(B, S0, S) |
M:NT |
M:NT(..., S0, S) |
H, PB --> B |
H(S0, S) :- B(S0, S1), S = PB ++ S1 (pushback) |
- Export and import nonterminals as
name//N(:- module(m, [greeting//0]),:- use_module(m, [greeting//0]));name/(N+2)names the same predicate, as in Scryer. !and->are refused in a grammar body exactly as in a clause body (this loader does not run cut), and so is{!}: in a rule it is the cut!, S0 = Sof the rule's own clause. Only a WHOLEphrase/2,3body!or{!}is local to the call, and answersS0 = S.\+in a grammar body is refused, as Scryer refuses it (representation_error(dcg_body)); negate a goal inside{...}.- A partial or improper terminal list (
[a|T],[a|b]) is refused at load, where Scryer raisesinstantiation_error/type_error(list, ...). use_module(library(dcgs))is accepted (phrase/2,3 are engine builtins) and, without an import list, installs Scryer'sop(1105, xfy, '|'), soA | Breads only after it -- as in Scryer.
What translates and what doesn't¶
Supported constructs¶
Most standard Prolog translates cleanly:
- Facts and rules (
:- bodybecomes<- (body)) - Arithmetic (
is, comparison operators) - Unification (
=becomesis;\=becomes the quoted ISO builtin'\\='(X, Y), a test run once, and\==becomes'\\=='(X, Y)-- not the delayedis not(dif/2) and!=(CLP) constraints, which answer differently when an argument is unbound) - Lists (
[H|T]becomes[H, *T]) - DCG rules (
-->becomes>>) - Directives (
dynamic,discontiguous,table,module,use_module), in the ISO call form:- dynamic(foo/1). - Negation as failure (
\+becomesnot) - Standard order of terms:
X @< Y(and@>,@=<,@>=) becomes the quoted ISO builtin'@<'(X, Y);compare/3crosses unchanged. (Refused until 2026-09-29, when the engine had had them for weeks.) - One name at several arities:
p(1).andp(1, 2).definep/1andp/2, two procedures, as in ISO;use_module(m, [p/1])imports one of them. bagof/3andsetof/3with the existential quantifier:Y^GoalbecomesY ^ (Goal)(nested to the right,A ^ (B ^ (Goal))), so the solutions group by the free variables as in ISO (8.10):setof(X, Y^p(X, Y), L)answers one list.
Unsupported constructs¶
The translator rejects programs containing:
- Cut (
!/0) — raisesSyntaxError. Useonce/1,dif/2, first-argument indexing, or constraints instead. - If-then-else (
(C -> T ; E)) — raisesSyntaxError. Use reified if-then-else, separate clauses withdif/2guards, or constraints.
These are rejected rather than silently mistranslated, because their semantics cannot be faithfully represented in the engine's cut-free core.
A query in program text (?- Goal.) is refused too: it is not run on
load, and until 2026-09-29 it was silently turned into a comment. An
:- op/3 directive is applied by the reader to the terms below it (its
effect on the program's text); Clausal Prolog has no run-time operator table, so
the directive itself is kept as a comment.
Atoms and strings¶
The translator preserves the ISO distinction: a Prolog atom loads as a Clausal Prolog atom, and a Prolog double-quoted string loads as a Clausal Prolog string.
p("ab"). % a STRING -- the list ['a','b']
q(red). % the ATOM red
r('hello world'). % the ATOM 'hello world'
translates to:
- A bare atom (
red) is emitted as a bare name and collected into an auto-generated-private([...])list, so it compiles under strict atoms with no work from you. - A quoted atom (
'hello world'), or one whose spelling is not a plain lowercase identifier or that collides with a Python keyword, is emitted single-quoted —'…'is an atom in every-double_quotesmode, so it stays an atom no matter what the engine default becomes. - A double-quoted string is emitted double-quoted, and the generated
module carries
-double_quotes(chars)(written above every other directive, because it governs the literals below it) so the literal re-reads as the string it was. The directive is written only when the file actually contains a string. true,falseandfailmap to PythonTrue/False(a :- true.becomesa() <- (True)).- The atom
undefinedis emitted quoted ('undefined'): bareundefinedis Clausal Prolog's truth valueUndefined, which-privatecannot declare (until 2026-09-29 a file holding the atom failed to load).
The ISO directive :- set_prolog_flag(double_quotes, Mode) (or the short
:- double_quotes(Mode)) governs every "…" below it, as in Scryer, and the
translator applies it at each literal: under chars (the default) "ab" is
the string "ab" (the chars [a, b]), under codes it is the list
[97, 98], and under atom it is the atom 'ab'. The module's own mode
follows for chars and atom (-double_quotes(atom)), so
current_prolog_flag(double_quotes, M) reports it; Clausal Prolog has no codes
module mode, so that directive becomes a comment while its literals are
still emitted as codes. Any other value is refused, naming the line (Scryer:
domain_error(flag_value, double_quotes+Value)). Until 2026-09-29 codes
was only a comment and the strings below it stayed chars.
Any other :- set_prolog_flag(Flag, Value). is carried across as the
directive -set_prolog_flag(Flag, 'Value')
(the value quoted, so fail stays an atom). A setting the engine refuses
(unknown = fail, say) is then a load-time error; see Prolog Flags.
ISO assert: assert_creates_dynamic is on¶
Every imported .pl module starts with the flag
assert_creates_dynamic set to true, so
the imported code gets ISO's assert (7.5.2(2)): assertz(counter(0)) creates
counter/1 as a dynamic procedure the first time, with no :- dynamic
declaration. A static predicate, a builtin and a declared data functor are
still refused with permission_error(modify, static_procedure, PI).
.seam modules keep the declare-first default (false).
The default is set by the loader, not written into the translation. A
:- set_prolog_flag(assert_creates_dynamic, false). in the file turns it
off for that module.
Singletons: _Name is deliberate¶
A .pl file follows the Prolog convention (ISO, Scryer): a variable whose
name starts with _ (_Y in g(L) :- setof(X, p(X, _Y), L).) is used
once on purpose, so the load does not warn about it. Any other variable
used once still gets ClausalSingletonWarning. This is the .pl loader's
rule (and the .clausal loader's): in .seam source every named variable used once
warns, whatever its spelling (see
singleton variables).
Loading .pl files programmatically¶
For tests and scripts that need to load a specific .pl file by path (rather
than relying on sys.path discovery):
from clausal.import_hook import _load_prolog_module
mod = _load_prolog_module("my_module", "/path/to/my_module.pl")
logic_module = mod.__clausal_module__
You can also specify a Prolog dialect:
from clausal.tools.prolog_dialect import Dialect
mod = _load_prolog_module("my_module", "/path/to/my_module.pl",
dialect=Dialect.scryer())
The default is Scryer's operator table (Dialect.scryer_reader()): ISO's
Table 7 plus Scryer's own defaults, prefix + (200, fy) and the infix div
and rdiv (400, yfx) -- what Scryer reports with no library loaded. It
replaced SWI's table on 2026-09-28. SWI's extra operators are therefore not
operators here, as they are not in Scryer: the prefix directive forms
(:- dynamic foo/1. -- write :- dynamic(foo/1).), *->, =@=, \=@=,
xor, and the dict operators :< and >:<. A file that needs one declares
it with :- op/3, which the reader applies as it goes, or pass
Dialect.swi() to read SWI source.
Running a .pl file's tests¶
test/1 clauses in a .pl file are tests, as in a .seam file: the
translator keeps the test name, so test('name') :- Body. is the runner's
test/1. Both runners collect .pl files:
python -m clausal.testing path/to/rules.pl # or a directory holding .pl files
python -m pytest path/to/rules.pl
A .pl file that fails to translate is reported as a failing <load> test
carrying the translator error (exit 1), never skipped. A .pl file with no
test/1 clauses exits 5 ("no tests collected") unless --allow-empty is
given. See Testing.
Error handling¶
Translation and parse errors are surfaced as SyntaxError, which Python's
import machinery displays clearly:
| Error type | Cause | Exception |
|---|---|---|
| Prolog parse error | Invalid Prolog syntax | SyntaxError |
| Translation error | Unsupported construct (cut, if-then-else) | SyntaxError |
| Encoding error | Non-UTF-8 .pl file |
SyntaxError |
| Import error | Missing module in use_module |
ImportError |
| Unmappable module spec | use_module(M), a path with no module |
SyntaxError naming the directive and line |
| Empty import list | use_module(M, []) (Scryer and Trealla disagree on it) |
SyntaxError naming the directive and line |
A translation error names the .pl line it comes from
(Cannot import foo.pl: line 12: Cut (!/0) ...).
Bytecode caching¶
Translated .pl files are cached as .pyc bytecode in __pycache__/, just
like .seam files. Cache invalidation is automatic — if you modify the
.pl file, the next import re-translates and recompiles.
The .pyc is keyed on the .pl file's mtime and size, and on a
fingerprint of the engine, including the translator itself (see
Caching), so:
- Editing the
.plfile invalidates the cache (triggers re-translation) - Upgrading or editing the engine invalidates it too
- Restarting Python loads from cache (no re-translation)
sys.dont_write_bytecode = Truesuppresses cache writes
Translation reference¶
For the full mapping between Prolog and seam syntax, see Prolog Translation.
The key operator mappings:
| Prolog | Seam |
|---|---|
:- |
<- |
= |
is |
\= |
'\\='(X, Y) (ISO "not unifiable", a test; not is not, which is the delayed dif/2) |
\== |
'\\=='(X, Y) (ISO term non-identity, a test; not !=, which is the CLP disequality) |
is |
eval_/2 |
=:= |
== |
=\= |
!= |
=< |
<= |
\+ |
not |
; |
or |
--> |
>> |
member(X, L) |
X in L |
X // Y, mod, rem, div, ^, **, <<, >>, /\, \/, \ |
the quoted ISO evaluable: '//'(X, Y), '^'(X, Y), ... |
max(X, Y), abs(X), sqrt(X), ... (any ISO evaluable) |
the same name |
Predicate names cross unchanged: foo_bar/2 stays foo_bar/2. A few
library predicates that the engine spells differently are renamed to the
engine predicate that answers the same (memberchk/2 → in_check/2,
nth0/3 → list_item/3, time/1 → time_goal/1, all_distinct/1 →
all_different/1, ...), but never a name the program defines, declares or
imports from its own modules — a file's own time/1 stays time/1 — and
never a name the engine already has (atomic/1). Until 2026-09-29
profile_get/3 was renamed to the engine's get/3 and atomic/1 to an
is_atomic/1 that does not exist; both now cross unchanged. A name
that collides with a Python keyword gets a trailing underscore (not/1
becomes not_/1), and a quoted functor whose name is not a plain lowercase
name is refused rather than translated — 'Foo', 'FOO' and '_foo' would
each be emitted as a name the seam reads as something other than a predicate
(a TitleCase identifier is a load-time error; the other two are logic
variables).
Variables keep their Prolog names — all of them, not just single letters.
X stays X, Head stays Head, _Ignored stays _Ignored: a
capital-initial identifier is a logic variable in the seam exactly as it is in
ISO Prolog. Until 2026-09-10 a multi-letter variable was lowercased and given
a leading underscore (Head became _head), which was not injective —
Head and HEAD both became _head — so a clause using both silently
merged them into one variable.
One spelling does not survive, and is refused rather than translated:
__Foo, because a leading double underscore is excluded from the variable
class. It is a legal ISO variable; the refusal names the Prolog variable and
suggests a spelling that works.
_PI_ was a second such case until 2026-09-11, when the seam read one leading
and one trailing underscore as a module constant
rather than a variable. Constants are spelled like atoms now, so _PI_ is an
ordinary variable and crosses untouched. (Before 2026-09-10 it was silently
renamed to _pi.)
Known limitations¶
- Some compound data terms are not declared. The translator declares a
program's bare atoms and the functors of its data terms (
p(f(1)).andq(X) :- X = g(2).addf(_)andg(_)to the-private([...])list). A name is left undeclared when the program defines, declares, imports or calls it as a predicate (including in a meta-predicate's goal argument, such asfindall(X, counter(X), L)), or when the engine knows it as a builtin or evaluable at that arity. A name used as data at two arities is declared at both (box(1)andbox(1, 2)addbox(_)andbox(_, _): a-privateentry's field names are per arity). A data term under a name that nothing else declares raises the ISO error termerror(existence_error(procedure, f/1), f/1)when it is built (acatch/3sees it; from Python it is also aNameError). - Arithmetic.
+ - * /cross as the seam's operators (Y is X / 2becomeseval_(X / 2, Y)), and=:=becomes the constraint==(see Operators). The ISO operators Python spells differently (//,mod,rem,div,^,**, the bit operators) cross as the quoted ISO evaluable ('^'(2, 3)), and every ISO evaluable function keeps its name, so these answer as in ISO:2 ** 3is8.0and2 ^ -1istype_error(float, 2). Until 2026-09-29max/min/abswere renamed tomax_/min_/abs_(atype_error(evaluable, max_/2)),^became Python**(2 ^ -1answered0.5) and<<,/\,\/,\were not evaluated at all. - A renamed library name is renamed in data position too. The few
renames that remain (
memberchk→in_check,float/1→float_/1, ...) apply wherever the name appears as a functor, because a meta-call's goal argument is emitted as a term;X = memberchk(a, L)buildsin_check(a, L). use_module/1of a.seammodule gives qualified access only (-import_module); name the predicates withuse_module/2.- No cut, no if-then-else (above), and no streams or
op/3. The ISO flags are there (Prolog Flags), butunknowncan only beerror.
Caveats¶
- The
.plextension is also used by Perl. If a Perl script ends up onsys.path, the import hook will attempt to parse it as Prolog and raise aSyntaxError-- even when a.seamor.clausalmodule of the same name sits in a latersys.pathentry, since the earlier entry wins.sys.path[0]is the script directory or the current directory, so a strayfoo.plthere shadows an installedfoo.seam. - All
.plfiles must be UTF-8 encoded. Non-UTF-8 files raise aSyntaxErrorat import time. - Avoid naming
.plfiles after standard modules. A file likejson.plonsys.pathcould shadowclausal.modules.json, and in the other direction-import_from(graphs, [...])in a seam module finds the standardgraphsmodule before agraphs.plof yours.
See also: For Prolog Programmers — syntax mapping and conceptual guide for Prolog users.
See also: Prolog Translation — CLI tools and full translation reference.
See also: Module System — the import directives and cross-module calls.
See also: Trealla Prolog Embedding — fast, lightweight in-process Prolog via C · Scryer Prolog Embedding — strict ISO conformance with tabling support. Both run Prolog on actual ISO engines alongside the native engine.