Exception Handling¶
Clausal Prolog provides structured exception handling via throw/1, catch/3, catch_error/2, catch_recover/3, halt/0, and halt/1. Error terms are ISO's error(Formal, Context), with the context filled the way Scryer fills it (ISO first; where ISO is silent, Scryer — never SWI). Exceptions are implemented via Python's native exception mechanism.
The implementation lives in clausal/logic/exceptions.py.
Syntax¶
throw/1¶
Raises an exception with a structured error term:
Any term can be thrown — strings, atoms, or structured error terms.
catch_error/2¶
Catches any exception and binds the error term to a variable or pattern:
-private([boom(N)])
test("catch_error/2 binds the ball") <- (
catch_error(throw(boom(42)), ERROR),
ERROR is boom(42)
)
- Goal — the goal to execute; all solutions pass through if no exception
- ERROR — unified against the thrown term on exception; may be a variable (catches all) or a structured pattern (selective catch with no re-raise on mismatch)
catch_error/2 never re-raises — it is equivalent to catch(Goal, ERROR, true) but with unified exception representation. Python exceptions appear as ClassName(Message) terms, identical in shape to logic throw/1 terms. (It was spelled Catch/2 before TitleCase names became a load-time error.)
catch_recover/3¶
Like catch_error/2 but with an explicit recovery goal:
- Goal — the goal to execute
- ERROR — unified against the thrown term on exception
- Recovery — goal run after ERROR is bound; has access to ERROR's bindings
catch_recover never re-raises on pattern mismatch. For selective catch with re-raise on mismatch, use catch/3.
catch/3¶
The standard form with selective matching and re-raise on mismatch:
safe_div(X, Y, R) <- catch(
eval_(X / Y, R),
error(evaluation_error('zero_divisor'), _),
R is 'undefined'
)
safe_div(1, 0, R) gives R = 'undefined'; safe_div(7, 2, R) gives R = 3.5.
The division is evaluated with eval_/2, which raises on a zero divisor. The
same division posted as a constraint, R == X / Y, does not raise: a zero
divisor inside a constraint makes it fail, so there is nothing to catch
(see Arithmetic and Operators).
catch(Goal, Catcher, Recovery):
- Goal — the goal to execute
- Catcher — a pattern unified against the thrown term; re-raises if no match
- Recovery — the goal to execute if the exception matches
catch/3 also intercepts plain Python exceptions raised inside the goal
(including from ++() escapes). These are wrapped as ClassName(Message) — a
term whose functor is the exception class name — so the catcher can match
them the same way as logic throw terms:
If the Python exception does not match the catcher it is re-raised unchanged.
This holds wherever in the goal the exception is raised — in the clause that
wrote the catch/3, or several predicate calls down. Whether a handler is live
never depends on how the engine chose to compile the call.
++ catchers: matching the Python exception object¶
The ClassName(Message) shape above matches by spelling. A catcher written
as a ++() escape instead matches the original
Python exception object:
risky(X) <- (X is ++int("nope"))
guarded_class(R) <- catch(risky(_A), ++ValueError, R is "caught")
guarded_msg(M) <- catch(risky(_B), ++ValueError(M), true)
- A catcher that evaluates to an exception class (
++ValueError) matches byisinstance— Python semantics, so++ArithmeticErrorcatches aZeroDivisionErrorand++Exceptioncatches any stray Python exception. - A catcher that evaluates to an exception instance (
++ValueError(M)) matches byisinstanceon its type and unifies itsargsagainst the real exception'sargs— bindingMto the actual message (as a string, the carrier('$chars', text)). Arity counts: a two-arg pattern only matches a two-arg exception. - A
++catcher never matches a logicthrow/1ball, so it stays selective in both directions.
Prefer ++ catchers when writing for portability: the seam's ALL_CAPS
variable rule means a bare ValueError(M) catcher is a functor here but
reads as a variable under ISO's initial-capital rule, silently widening a
specific catcher to a catch-all if the code is ever translated outward. The
marked ++ form makes the Python-specific region visible, and a translator
must handle it deliberately.
The two things catch/3 does not intercept are the signals that are
control flow rather than errors: halt/0 and halt/1 (SystemExit),
KeyboardInterrupt, and abandoning a solution iterator early (GeneratorExit).
Those are BaseExceptions and pass straight through any handler.
halt/0, halt/1¶
halt() raises SystemExit(0). halt(N) raises SystemExit(N).
Unified Exception Representation¶
Both logic throw/1 terms and Python exceptions are represented as plain
terms during catch. Python exceptions become the term
ClassName(Message) (the cell ('ClassName', message) in Python) — the same
structural shape as any predicate term — so there is
no distinction between catching a logic throw and catching a Python exception:
-private([my_error(N)])
risky_int(X) <- (X is ++int("nope"))
test("a logic throw and a Python exception are caught alike") <- (
catch_error(throw(my_error(42)), E1),
E1 is my_error(42),
catch_error(risky_int(_), E2),
functor(E2, NAME, 1), # E2 = 'ValueError'(Message)
NAME is 'ValueError'
)
Structured Error Terms¶
Clausal Prolog follows the ISO Prolog convention of wrapping errors in error(ErrorTerm, Context) compounds. Helper functions in clausal.logic.exceptions build these:
| Helper | Error term |
|---|---|
type_error(valid_type, culprit) |
error(type_error(Type, Culprit), ...) |
instantiation_error() |
error(instantiation_error, ...) |
existence_error(object_type, culprit) |
error(existence_error(Type, Culprit), ...) |
permission_error(op, type, culprit) |
error(permission_error(Op, Type, Culprit), ...) |
evaluation_error(kind) |
error(evaluation_error(Kind), ...) |
domain_error(domain, culprit) |
error(domain_error(Domain, Culprit), ...) |
The second argument of error/2 is what Scryer puts there. The rule for
this engine's error terms: where Scryer and SWI-Prolog differ, follow Scryer.
- For a builtin, it is that builtin's predicate indicator:
error(type_error(atom,1),atom_length/2),error(evaluation_error(zero_divisor),(/)/2). - For a missing procedure, it is the missing indicator itself, whichever
builtin found it missing:
error(existence_error(procedure,foo/1),foo/1). A database builtin refusing a target nothing declares (assertz(foo(a))when onlyfoo/2is-dynamic) is not a missing procedure but a write to a static one — ISO 7.5.2 makes a user procedure static by default — so it raiseserror(permission_error(modify,static_procedure,foo/1),assertz/1), the same term as for a declared static predicate: declare it-dynamic(foo/1)first. - When there is no single culprit indicator, it is an unbound variable, as
Scryer's own library code throws
error(E, _).
It is not SWI-Prolog's context(Culprit, Message). Explanatory text stays off
the term. Every builder takes the context as text: "atom_length/2" gives
atom_length/2, "solve/1: the goal is unbound" gives solve/1 with the
prose the goal is unbound, and text without a leading Name/Arity gives an
unbound variable with the whole text as prose. The prose is the Python
exception's .message, and str() prints it after the term, separated by
": ":
The prose belongs to the Python exception. A term caught with catch/3 is
exactly the Scryer term and carries no prose of its own; when it is thrown
again the new exception recovers the prose on a best-effort basis (it is kept
for the most recent error terms only).
test("the formal term and the culprit indicator") <- (
catch(atom_length(1, _), error(type_error(T, V), _), true),
T is 'atom',
V is 1,
catch(atom_length(1, _), error(_, PI), true),
PI is '/'('atom_length', 2)
)
The type, domain and kind names inside an error term are atoms. Under the
default -double_quotes(chars), "atom" is a string, so a pattern written
error(type_error("atom", _), _) never matches and the error propagates past
the catch. Write 'atom' (or bare atom). A load-time
ClausalStringInCatchPatternWarning flags a double-quoted string at those
positions in the catcher of catch/3, catch_recover/3 and catch_error/2.
The culprit (the last argument of type_error/2 and its kin) is not judged:
it is the offending term itself, and may be a string.
Reading an error term from Python¶
An error term is a plain cell: a tuple whose first element is the functor.
clausal.cell_functor and clausal.cell_args read it, and clausal.make_cell
builds one. In a .seam file, a goal in goal position raises
the uncaught error straight into the Python around it:
# errs.seam
from clausal import LogicException, cell_args, cell_functor
bad(N) <- atom_length(1, N)
def main():
try:
for N in --bad(N):
pass
except LogicException as e:
print(e) # Uncaught logic exception: error(type_error(atom,1),atom_length/2)
print(cell_functor(e.term)) # error
formal, indicator = cell_args(e.term)
print(formal, indicator) # ('type_error', 'atom', 1) ('/', 'atom_length', 2)
print(e.message) # None -- no prose for this error
(python -c "import clausal, errs; errs.main()".) The same exception reaches
a plain .py caller of solve().
Modifying a static predicate¶
assertz/1, asserta/1 and retract/1 work only on predicates declared
-dynamic — declare first, then modify. A clause for a predicate that is
defined but not dynamic — or, for assertz/asserta, one nothing declares,
which is static by default — raises ISO's permission_error; retract of a
name nothing declares fails (ISO 8.9.3). The prose names the fix:
Uncaught logic exception: error(permission_error(modify,static_procedure,fixed/1),assertz/1): fixed/1 is a static procedure — declare it -dynamic(fixed/1) to modify it at runtime
-dynamic(counter/1)
counter(0),
fixed(1),
test("a dynamic predicate accepts assertz") <- (
assertz(counter(1)),
findall(C, counter(C), [0, 1])
)
test("a static one raises permission_error") <- (
catch(assertz(fixed(2)), error(permission_error(M, T, PI), _), true),
M is 'modify',
T is 'static_procedure',
PI is '/'('fixed', 1)
)
A name the module neither defines nor declares is refused before it gets that far: the goal cannot be built at all.
Raising well-formedness guards in library code¶
Shared library predicates (a project's library/ modules, for example) often want to
raise on malformed input — a non-ground term, the wrong shape, the wrong type —
rather than fail logically. Logical failure inside a findall is indistinguishable
from a legitimate empty result: the findall collapses to [], a downstream
aggregation (max_list, etc.) returns its default, and a wrong verdict propagates
silently with nothing to locate. A raised exception, by contrast, travels out of the
findall and lands on the loud, well-diagnosed RAISED channel — the same channel a
Python-side exception reaches.
No special primitive is needed: throw/1 already does this, and an exception thrown
inside a findall body propagates out of it rather than being swallowed as a logical
failure. Write the guard as an ordinary clause that throws when the input is
malformed. Declare the error functor in -private([...]) so it constructs a term under
the strict-atoms default instead of tripping the
undeclared-atom guard:
-private([is_ymd_triple(REF), wf_bad_shape(MSG, CULPRIT)])
is_ymd_triple([Y, M, D]) <- (integer(Y), integer(M), integer(D))
window_days_used(REF_YMD, _DAYS_UNUSED) <- (
not is_ymd_triple(REF_YMD),
throw(wf_bad_shape("window_days_used: REF_YMD must be [Y,M,D]", REF_YMD))
)
window_days_used([_Y_UNUSED, _M_UNUSED, _D_UNUSED], 7),
test("malformed input raises, not a silent empty findall") <- (
catch(
findall(D, window_days_used("2020-01-01", D), _DAYS_UNUSED),
wf_bad_shape(_MSG_UNUSED, CULPRIT),
CULPRIT == "2020-01-01"
)
) # nv
The thrown term carries both a human-readable message and the offending term, so a test
report (or an outer catch/3) names the bad argument, not just the predicate. Uncaught,
it surfaces on the raised: line of the failure diagnostic, naming the findall the
throw escaped from.
To use the ISO error(...) taxonomy above instead of your own functor, import the
constructor from clausal.logic.exceptions — the helper builds the nested error(...)
term for you, so no functor declaration is needed:
from clausal.logic.exceptions import type_error
-private([is_ymd_triple(REF)])
is_ymd_triple([Y, M, D]) <- (integer(Y), integer(M), integer(D))
window_days_used(REF_YMD, _DAYS_UNUSED) <- (
not is_ymd_triple(REF_YMD),
throw(type_error("[Y,M,D]", REF_YMD))
)
window_days_used([_Y_UNUSED, _M_UNUSED, _D_UNUSED], 7),
test("iso type_error term raises from a guard") <- (
catch(
findall(D, window_days_used("2020-01-01", D), _DAYS_UNUSED),
error(type_error(_T_UNUSED, CULPRIT), _CTX_UNUSED),
CULPRIT == "2020-01-01"
)
) # nv
Regression coverage for the propagation-through-findall behaviour lives in
tests/test_exceptions.py::TestRaisingGuardThroughFindAll (with the fixture
tests/fixtures/raising_guard_lib.seam).
LogicException¶
LogicException is a Python exception class that wraps a thrown logic term:
from clausal import LogicException
try:
... # run a query that throws
except LogicException as e:
print(e.term) # the thrown term, a plain cell
print(e.message) # the explanatory prose, or None
An uncaught throw/1 surfaces as LogicException in Python code; str(e) is
Uncaught logic exception: followed by the term as Scryer prints it, then
: and the prose when there is any. Caught exceptions (via catch/3,
catch_error/2, catch_recover/3) never leave the logic layer.
Examples
Catch a type error:
check_int(X, R) <- catch(
(X > 0, R is "positive"),
error(type_error(_, _), _),
R is "not a number"
)
Catch a Python exception (no recovery needed):
catch_recover with error access:
Re-throw after logging:
Catch-all:
Python API
from clausal.logic.exceptions import LogicException, type_error, instantiation_error
# Build an error term
err = type_error("integer", "foo", "succ/2: the first argument")
# → ('error', ('type_error', 'integer', 'foo'), ('/', 'succ', 2))
# Raise from Python
raise LogicException(err)
# The message shows the term as Scryer prints an uncaught error
# (writeq text with operators), not its Python repr, then the prose;
# a variable that occurs once prints as `_`:
# Uncaught logic exception: error(type_error(integer,foo),succ/2): the first argument
Compiler Integration¶
throw(term)compiles toraise LogicException(term)catch(goal, catcher, recovery)compiles to atry/except Exceptionblock;LogicExceptionyields.termdirectly, any other Python exception is wrapped asClassName(message)before being unified against the catcher pattern; re-raises if no matchcatch_error(goal, error)— likecatch/3but always catches (no re-raise); recovery =truecatch_recover(goal, error, recovery)— likecatch/3but always catches (no re-raise)halt()/halt(N)compile toraise SystemExit(0)/raise SystemExit(N)
Test coverage
Tests are in tests/test_exceptions.py and
tests/test_units.py::TestPythonExceptionCatch.
- Throw: ground term, string, structured error, uncaught surfaces as LogicException
- Catch: matching/non-matching catcher, nested catch, recovery goal, variable catcher (catch-all)
- Python exceptions:
UnitsMismatchcaught viacatch_error/2asUnitsMismatch(Msg), message bound, transparent when no error, unmatched exception re-raised viacatch/3 - Halt: exit code 0, exit code N, raises SystemExit
- Structured errors: type_error, instantiation_error, existence_error, permission_error, evaluation_error
- Import integration:
.seamfile with catch/throw patterns
See also: Builtins — for a list of built-in predicates that can throw exceptions, Control — once and time_goal for execution flow.