Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture

patch-prolog compiles an ISO-subset Prolog program to a standalone native binary. The compiled binary contains no clause interpreter: predicates are native functions generated as LLVM IR; only primitive services (heap, trail, unification, builtins, query parsing, output) come from a runtime library statically linked into the binary.

Pipeline

rules.pl ──parse──▶ AST ──analyze──▶ codegen ──▶ rules.ll (LLVM IR text)
                                                    │
        libplg_runtime.a (embedded in plgc) ──┐     │
                                              ▼     ▼
                          clang -O3 -g rules.ll -lplg_runtime -lm
                                              │
                                              ▼
                                   rules  (standalone binary)
  • The compiler emits LLVM IR as text — no llvm-sys/inkwell binding, no LLVM version lock-in beyond “clang ≥ 15” (opaque pointers).
  • libplg_runtime.a is built from crates/runtime and embedded into the plgc binary via include_bytes! (set up by crates/compiler/build.rs, which also enforces exact version match between compiler and runtime, and bakes in a content hash of the archive). At link time it is materialized at a content-addressed cache path ($XDG_CACHE_HOME/plgc/runtime-<hash>/, else ~/.cache/plgc/…) that every run of the same build reuses; stale entries are age-swept. HOME-less environments fall back to a private per-link extraction that is removed after linking.
  • -Wl,--gc-sections (Linux) / -Wl,-dead_strip (macOS) strips runtime code the program can’t reach, keeping binaries small.
  • Users of plgc need clang. Users of compiled binaries need nothing.

This is the architecture proven by patch-seq: a compiled binary contains no clause interpreter. The rejected alternative — embedding a serialized clause database inside a shipped interpreter — would put the whole interpreter (and a Rust runtime) into every “compiled” program, and is why this engine generates native code per predicate instead.

Crates

CrateArtifactRole
plg-sharedrlibAtomId + well-known atoms, Term, StringInterner, FirstArgKey, operator table. Linked into BOTH compiler and runtime — zero dependencies, by rule.
plg-frontendrlibTokenizer + operator-precedence parser + ISO error types. Compiler-side only.
plg-runtimestaticlib + rlibThe machine substrate compiled code calls into: heap/trail/choice points, generic unify, ~60 builtins, the minimal goal-only --query parser, text/bson wire output, process entry. Ships inside every compiled binary.
plg-compilerbin plgc + rlibCLI, codegen (IR text emission), clang driver, runtime embedding.
plg-lspbin plglLanguage server (diagnostics, completion, hover, goto-definition). A frontend consumer — never links the runtime.
plg-replbin plgrInteractive REPL that drives the compiler; never interprets.

Dependency rule: nothing heavy (clap, serde, …) may enter plg-runtime or plg-shared; every byte there lands in every user binary. (The compiler-side crates — plg-frontend, plg-compiler, plg-lsp, plg-repl — are dev tooling and carry no such constraint.) This is a strong default, not an absolute: a dependency that demonstrably pays for its bytes against the size gate, or is scoped to compiler-side crates, can be considered.

Execution model (summary)

The runtime substrate — cell heap, trail, choice points, tagged words, generic unification, first-argument indexing, cut as choice-point-stack truncation — is the Edinburgh / Warren Abstract Machine (WAM) model [Warren, SRI Tech Note 309, 1983], the shared machine model behind DEC-10/Quintus/SICStus/SWI/GNU Prolog. plgc inherits that machine model and diverges from WAM only in the codegen target: where a classical WAM system compiles Prolog to an instruction set (get/put/unify, call/proceed) over a register file and a local-stack environment, plgc compiles each predicate to one native LLVM function in continuation-passing style. The data structures are WAM’s; the instruction set is replaced by direct native-code emission. See Compilation Model for the why and the escape hatches.

  • Each predicate compiles to one LLVM function in continuation-passing style: it receives the Machine pointer, its arguments as tagged 64-bit words, and a success continuation. Solutions are delivered by musttail-calling the continuation; failure is a plain return.
  • Alternatives (untried clauses, disjunction branches) live on a runtime-managed choice-point stack holding retry function pointers plus heap/trail marks. Backtracking = rewind marks + tail-call retry.
  • Cut truncates the choice-point stack to the barrier recorded at predicate entry (stopping at catch frames).
  • All transfers are tail calls and continuation frames are heap-allocated, so Prolog recursion depth never grows the C stack; determinate last-goal recursion is a true jump.
  • Backtracking resets the heap top to the choice point’s mark — memory reclamation without GC.

Runtime --query support

The compiler bakes two global tables into every binary:

  • the atom table (all atom names, in id order), and
  • the predicate registry {functor_id, arity, fn_ptr}.

At startup the runtime rebuilds the name→id map from the atom table, so a runtime-parsed query interns into the same id space (new atoms get fresh ids that correctly unify with nothing in the program). The registry maps a parsed goal to its compiled entry point — this is also how call/1 and findall/3 re-enter compiled code. Predicates declared :- dynamic with no clauses are registered to an always-fail stub (silent-fail linter contract); unknown predicates raise existence_error(procedure, F/A).

Wire contract (compiled binaries)

The engine speaks two wire encodings, no JSON: text (readable, the X = foo form, default) and bson (binary, dense, typed). A program declares which its binary advertises via :- io_format([...]) (default [text, bson] — both core formats available out of the box; the directive is opt-out, to restrict); --format/--input-format outside that set exit 2. Encoders not advertised are dead-stripped from the binary (--gc-sections). A host wanting JSON derives it from bson at the host boundary — the engine never serializes JSON. (In the worker environment that “host” is two layers: reactor.mjs, which plgc emits and which owns everything coupled to engine internals (bson decode, atom map, wasm ABI); and the service’s own handler, which owns JSON in — there is no structured input path, data reaches a query only as Prolog syntax in the goal string — and API-shaped JSON out. See examples/coldchain/README.md.)

  • --query "goal(X)", --limit N, --format text|bson, --input-format text|bson
  • exit 0 no solutions · 1 solutions found · 2 parse/usage error · 3 runtime error
  • text: the readable solutions form (X = foo / true. / false.); projects the envelope to solutions (no count/exhausted).
  • bson: a self-delimiting bson document {count, exhausted, solutions[], output?}; term values are BinData(0x00) carrying a lossless TermBuf (cyclic terms round-trip; text cuts them).

Build system

justfile is the source of truth; CI (.forgejo/workflows/ci-linux.yml) only calls just ci. Recipe ordering matters: build runs build-runtime before build-compiler so the canonical target/release/libplg_runtime.a is fresh when build.rs embeds it.