Skip to content

IPython / Jupyter REPL

Clausal Prolog has first-class IPython integration that turns an IPython session into a Prolog-style REPL. Queries use a *(goals) syntax, uppercase names are automatically treated as logic variables, and solutions are presented interactively one at a time — separated by or, just as the seam's disjunction operator reads. For notebook-specific rendering, see Jupyter Notebooks.


Quick start

After setting up the IPython startup hook (one-time, per profile):

# Cell 1 — load a module
import clausal.examples.sudoku as sudoku

# Cell 2 — query
*(sudoku.problem(1, ROWS), sudoku.sudoku(ROWS))

ROWS is declared automatically as a fresh logic variable — no Var() needed.


Set up the IPython startup hook

IPython parses each cell before executing any code in it, so the AST transformer that rewrites -import_from(...) and *(...) queries has to be registered before your first cell runs. The cleanest way is an IPython startup file that imports clausal once at session start.

clausal-install-ipython

This drops a single file at ~/.ipython/profile_default/startup/00-clausal.py. Every IPython session in that profile picks it up automatically; you can delete the file to opt back out. Useful flags:

clausal-install-ipython --profile myprofile   # different IPython profile
clausal-install-ipython --force               # overwrite existing file
clausal-install-ipython --uninstall           # remove the hook
clausal-install-ipython --print               # print the script to stdout

Manual — write the file yourself

If you'd rather not run the install command, the hook is a five-line file you can copy in by hand:

# ~/.ipython/profile_default/startup/00-clausal.py
try:
    from clausal.import_hook import enable_ipython as _enable_clausal
    _enable_clausal(get_ipython().user_ns)
except ImportError:
    pass

Per-session — call enable_ipython from cell 1

Without a startup file, every session needs an explicit setup cell:

from clausal.import_hook import enable_ipython
enable_ipython(globals())

After that, subsequent cells can use *(...) and -import_from(...).

Environment-variable opt-in (advanced)

Setting CLAUSAL_IPYTHON=1 makes import clausal itself register the AST transformer — handy if your workflow already imports clausal at the top of every session, but on its own it doesn't help: until something imports clausal, IPython has no way to know about the integration. Use this in combination with a startup file or a --ext IPython extension that imports clausal during startup:

# ~/.zshrc or ~/.bashrc
export CLAUSAL_IPYTHON=1

Query syntax

There are two ways to query in IPython. (The goal-position seam, for X in --goal: / if --goal:, which is how Python hosted in a .seam file queries, is not available here: it needs a host seam module, and the REPL has none. It raises a NameError that says so. Put such code in a .seam file and import it — see Python Integration.)

Plain Python: solve with a goal cell

Build the goal as a cell — the predicate's name followed by its arguments — and run it against the module that defines it, with a standard for loop:

from clausal import Var, solve
import hello

for trail in solve(("greeting", X := Var()), module=hello):
    print(X.value)

This works identically in plain Python scripts and IPython. A module attribute such as hello.greeting is the predicate's handle (a str), not something to call — see Querying from Python.

*(goals) — IPython-specific shorthand

The *(goals) form is an IPython-specific shorthand that auto-declares uppercase variables and displays solutions interactively. It is rewritten at the text level into a valid Python call before compilation (for compatibility with Python 3.13+).

The entire expression inside *(...) is treated as a seam clause body, not ordinary Python. Operators that have special meaning in Python are rewritten into goals:

Inside *(...) Goal constructed
X is Y Unify(left=X, right=Y)
X == Y ArithEq(left=X, right=Y)
X != Y ArithNeq(left=X, right=Y)
X < Y Lt(left=X, right=Y)
A and B And(left=A, right=B)
A or B Or(left=A, right=B)

Single goal

*(solve(ROWS))       # after -import_from(clausal.examples.sudoku, [solve])

Conjunction — comma-separated

*(problem(1, ROWS), sudoku(ROWS))    # after -import_from(clausal.examples.sudoku, [problem, sudoku])

Multiple comma-separated goals are folded into a left-associative And chain and share a single solver context for correct backtracking.

Unification goal

*(X is ['asdfa', 1, Y])

is inside *(...) is unification, not Python identity. The answer prints as X is [asdfa, 1, _], Y is _.


Variable auto-declaration

Any uppercase name inside a *(...) query is automatically allocated as a fresh Var() via a walrus assignment embedded in the goal expression itself:

*(X in [1, 2, 3], X > 1)
# X is allocated as Var() automatically

This mirrors Prolog's convention that uppercase identifiers are variables. If you need to share a variable across multiple cells, declare it explicitly with X = Var() before the query.


Interactive solution browsing

Solutions are presented one at a time with an or separator between them, matching the seam's disjunction syntax:

ROWS is [[1, 5, 6, ...], ...]
   [SPACE/n: next  |  ENTER/.: stop  |  ESC/q: abort  |  a: all]

When there is nothing more to show, the display ends with # No more solutions.

Key bindings follow standard Prolog REPL conventions:

Key Action
SPACE, n next solution
ENTER, . stop (commit to current solution)
ESC, q abort (no output)
a show all remaining solutions

Terminal colours

In a terminal IPython session (ipython command, not Jupyter), ANSI colours are enabled automatically. Unbound variables, atoms, numbers, strings, and brackets each get a distinct colour with rainbow bracket-depth cycling.

Control the style from any cell using the injected helpers:

# Disable colours
set_style(TermStyle())

# Re-enable default colours
set_style(TermStyle(colors=ANSI_COLORS))

# Change the anonymous-variable symbol (default: '_')
set_style(TermStyle(anon_var='?'))

# Custom colour scheme
set_style(TermStyle(colors={
    'number':   '\033[33m',
    'string':   '\033[32m',
    'atom':     '\033[36m',
    'var':      '\033[35m',
    'brackets': ['\033[91m', '\033[93m', '\033[92m', '\033[96m', '\033[94m', '\033[95m'],
    'reset':    '\033[0m',
}))

set_style, TermStyle, and ANSI_COLORS are automatically injected into the IPython namespace by enable_ipython. Colours are not enabled in Jupyter kernels, which render output as HTML rather than a terminal.


Using Solutions directly

For programmatic use, wrap any iterator of binding dicts. Run the goals with call — the predicate's name, its arguments, and the module that defines it — on one shared Trail, so the second goal sees the first one's bindings. problem/2 fetches a puzzle and solve/1 constrains and labels it:

from clausal import Var, call, Solutions
from clausal.logic.variables import walk, Trail
from clausal.examples import sudoku

ROWS = Var()
trail = Trail()

def gen():
    for _ in call("problem", 1, ROWS, module=sudoku, trail=trail):
        for _ in call("solve", ROWS, module=sudoku, trail=trail):
            yield {'ROWS': walk(ROWS)}

Solutions(gen())

For a single goal, solve with a cell does the same job. This one only fetches puzzle 1 — its blanks stay unbound variables; solving it is the second goal above:

from clausal import Var, solve, Solutions
from clausal.logic.variables import walk
from clausal.examples import sudoku

ROWS = Var()
Solutions({"ROWS": walk(ROWS)}
          for _ in solve(("problem", 1, ROWS), module=sudoku))

Solutions does not run a goal cell handed to it directly — a cell is a tuple, and Solutions(("problem", 1, ROWS)) would iterate its elements. Pass the iterator that solve or call returns.