Skip to content

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:

throw(error(type_error(integer, foo), context))

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:

catch_recover(Goal, ERROR, Recovery)
  • 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:

catch(
    ++(some_python_call()),
    ++ValueError(MSG),
    handle_error(MSG)
)

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 by isinstance — Python semantics, so ++ArithmeticError catches a ZeroDivisionError and ++Exception catches any stray Python exception.
  • A catcher that evaluates to an exception instance (++ValueError(M)) matches by isinstance on its type and unifies its args against the real exception's args — binding M to 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 logic throw/1 ball, 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

done <- halt()
done_with_code <- 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 only foo/2 is -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 raises error(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 ": ":

Uncaught logic exception: error(instantiation_error,solve/1): the goal is unbound

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):

safe_parse(S, R) <- (
    catch(++(int(S)), ++ValueError(_)),
    R is "parse error"
)

catch_recover with error access:

logged_op(X, Y, R) <- catch_recover(
    (R == X / Y),
    ERR,
    (write_text(ERR), R is "error")
)

Re-throw after logging:

logged_div(X, Y, R) <- catch(
    (R == X / Y),
    E,
    (write_text(E), throw(E))
)

Catch-all:

safe_run(GOAL, R) <- (
    catch(call_goal(GOAL), _),
    R is "ok"
)


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 to raise LogicException(term)
  • catch(goal, catcher, recovery) compiles to a try/except Exception block; LogicException yields .term directly, any other Python exception is wrapped as ClassName(message) before being unified against the catcher pattern; re-raises if no match
  • catch_error(goal, error) — like catch/3 but always catches (no re-raise); recovery = true
  • catch_recover(goal, error, recovery) — like catch/3 but always catches (no re-raise)
  • halt() / halt(N) compile to raise 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: UnitsMismatch caught via catch_error/2 as UnitsMismatch(Msg), message bound, transparent when no error, unmatched exception re-raised via catch/3
  • Halt: exit code 0, exit code N, raises SystemExit
  • Structured errors: type_error, instantiation_error, existence_error, permission_error, evaluation_error
  • Import integration: .seam file 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.