Skip to content

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:

edge(1, 2).
edge(2, 3).
edge(3, 4).

path(X, Y) :- edge(X, Y).
path(X, Z) :- edge(X, Y), path(Y, Z).

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:

path(1, 2)
path(2, 3)
path(3, 4)
path(1, 3)
path(1, 4)
path(2, 4)

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:

error(permission_error(access, prolog_module, M), Context)
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/N entry names a predicate, so it never resolves to data: with no name/N in the module it is still an ImportError.
  • The imported name also builds terms at any arity, positionally: --cite(++k) and a clause's cite(K) are ('cite', K), the rulebase's cite(k). So does a data functor the .pl module uses and you import (v above), 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 (citaton for citation) warns once, with ClausalImportedDataNameWarning, naming the predicate.
  • Only .pl targets: a .seam module exports its data in its -module(...) list, and a name missing there is still an ImportError.

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 raise AttributeError.
  • Tooling gets no answer: importlib's submodule probe, the Python standard library (unittest's load_tests, doctest, pickle, inspect) and tools such as pytest and Sphinx still see AttributeError, so their hook lookups (getattr(mod, 'load_tests', None)) behave as before. Only your own code gets the atom.
  • A submodule of a .pl package that is not imported yet is not data: from pkg import sub still imports it.
  • In Python code, from mod import name is getattr(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 .pl package: from pkg import nosuchsub gives the atom 'nosuchsub'. In seam code the same name goes through the seam -import_from rules above.
  • A near miss of a predicate (citations.citaton) warns once per module and name, with ClausalImportedDataNameWarning.
  • .seam and 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:

% m.pl
:- module(m, [rate/1]).
rate(X) :- helper(X).
helper(5).
-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/N entry when the module defines p/N and does not export it. A bare name exported at some arities imports only those: with p/1 exported and p/2 defined but not exported, -import_from(m, [p]) (or use_module(m, [p])) is -import_from(m, [p/1]), so a call p(X, Y) through it raises existence_error(procedure, p/2) and the importer may define its own p/2.
  • A circular import is not checked (the exporter is still loading).
  • A .pl file with no module/2 directive 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 unexported helper/1 silently and imports nothing, so the later call raises existence_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:

  • Name names 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:

  1. the file's own :- set_prolog_flag(require_end_module, true). (or false) -- it governs that file only;
  2. the process-wide setting: CLAUSAL_REQUIRE_END_MODULE=1 (or 0) in the environment, clausal.end_module.set_require_end_module(True | False | None) from Python, or set_prolog_flag(require_end_module, V) run as a goal (V is true, false or default); current_prolog_flag/2 reads it;
  3. the surface's default: .pl does 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:

  1. Engine-shipped modules: the library(...) facades, the engine stdlib, the engine's own py adapters (py.datetime, also spelled by its seam alias date_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 the clausal distribution's own RECORD; for an editable install or a source checkout, it lies under the engine's package directory (the directory of clausal/__init__.py, when that is not inside a site-packages tree). When neither can be established, nothing counts as engine-shipped (fail closed). Not by the name: an optional clausal-* package splices its adapters into the same namespace (clausal.modules.py.scipy_stats), and those are not the engine's -- a .seam importing one is a Python bridge. A .seam module 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 DIRECT use_module(py/X) stays refused as above: it imports the facade.)
  2. A .seam module 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 call m.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's module/2 list; for an engine Python module, its predicates as clausal.module_signatures lists them plus its values -- units, currencies, numbers -- exactly what its library(...) facade re-exports). A dotted chain must be exactly module.export or module.export(...); anything deeper (py.csv.io.open(...)), an attribute that is no export (py.files.pathlib), or any attribute of an imported name (zz.Path after alias(pathlib, zz)) is a route, and so is anything that does not resolve (fail closed). A term constructor such as py.datetime's date/3 is 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 .clausal file (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 and sys.argv play no part -- a program that loads another repository's .clausal files gets THAT repository's allowlist.
  • Entries: a string containing / or ending in .seam is a path (relative to the pyproject.toml); any other string is the dotted module name the import resolves to. A table takes exactly one of module or path, and optionally sha256 (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 .seam importer loaded first is refused just the same. A qualified call helper:cwd(D) (or call/N of 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), _), which catch/3 can match.
  • .seam and .pl importers are not affected: .seam is 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, including constant(X) or constant(f(a)), is a load error naming the .pl line.
  • Units come only from these declarations. 5*euro is the ordinary ISO term '*'(5, euro); make_quantity/3 stays available. Comparing a quantity with a plain number or another unit raises system_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 as european_union) imports the value name, as -import_from does; on a Prolog module a bare name the module exports imports exactly its exported arities ([p] is [p/1] when p/1 is 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) (which is/2 evaluates to 6), an atom is that atom, and "..." follows the double_quotes flag 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/2 builds, get/3 reads softly (fails on a missing key), get_strict/3 reads strictly (existence_error(dict_key, Key)), dict_put/4 and dict_put_pairs/3 update.

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 = S of the rule's own clause. Only a WHOLE phrase/2,3 body ! or {!} is local to the call, and answers S0 = 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 raises instantiation_error / type_error(list, ...).
  • use_module(library(dcgs)) is accepted (phrase/2,3 are engine builtins) and, without an import list, installs Scryer's op(1105, xfy, '|'), so A | B reads only after it -- as in Scryer.

What translates and what doesn't

Supported constructs

Most standard Prolog translates cleanly:

  • Facts and rules (:- body becomes <- (body))
  • Arithmetic (is, comparison operators)
  • Unification (= becomes is; \= becomes the quoted ISO builtin '\\='(X, Y), a test run once, and \== becomes '\\=='(X, Y) -- not the delayed is 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 (\+ becomes not)
  • Standard order of terms: X @< Y (and @>, @=<, @>=) becomes the quoted ISO builtin '@<'(X, Y); compare/3 crosses unchanged. (Refused until 2026-09-29, when the engine had had them for weeks.)
  • One name at several arities: p(1). and p(1, 2). define p/1 and p/2, two procedures, as in ISO; use_module(m, [p/1]) imports one of them.
  • bagof/3 and setof/3 with the existential quantifier: Y^Goal becomes Y ^ (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) — raises SyntaxError. Use once/1, dif/2, first-argument indexing, or constraints instead.
  • If-then-else ((C -> T ; E)) — raises SyntaxError. Use reified if-then-else, separate clauses with dif/2 guards, 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:

-double_quotes(chars)
-private([red])

p("ab"),

q(red),

r('hello world'),
  • 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_quotes mode, 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, false and fail map to Python True/False (a :- true. becomes a() <- (True)).
  • The atom undefined is emitted quoted ('undefined'): bare undefined is Clausal Prolog's truth value Undefined, which -private cannot 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:

SyntaxError: Cannot import foo.pl: Cut (!/0) cannot be translated to Clausal.
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 .pl file 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 = True suppresses 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)). and q(X) :- X = g(2). add f(_) and g(_) 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 as findall(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) and box(1, 2) add box(_) and box(_, _): a -private entry's field names are per arity). A data term under a name that nothing else declares raises the ISO error term error(existence_error(procedure, f/1), f/1) when it is built (a catch/3 sees it; from Python it is also a NameError).
  • Arithmetic. + - * / cross as the seam's operators (Y is X / 2 becomes eval_(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 ** 3 is 8.0 and 2 ^ -1 is type_error(float, 2). Until 2026-09-29 max/min/abs were renamed to max_/min_/abs_ (a type_error(evaluable, max_/2)), ^ became Python ** (2 ^ -1 answered 0.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) builds in_check(a, L).
  • use_module/1 of a .seam module gives qualified access only (-import_module); name the predicates with use_module/2.
  • No cut, no if-then-else (above), and no streams or op/3. The ISO flags are there (Prolog Flags), but unknown can only be error.

Caveats

  • The .pl extension is also used by Perl. If a Perl script ends up on sys.path, the import hook will attempt to parse it as Prolog and raise a SyntaxError -- even when a .seam or .clausal module of the same name sits in a later sys.path entry, since the earlier entry wins. sys.path[0] is the script directory or the current directory, so a stray foo.pl there shadows an installed foo.seam.
  • All .pl files must be UTF-8 encoded. Non-UTF-8 files raise a SyntaxError at import time.
  • Avoid naming .pl files after standard modules. A file like json.pl on sys.path could shadow clausal.modules.json, and in the other direction -import_from(graphs, [...]) in a seam module finds the standard graphs module before a graphs.pl of 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.