Python
Python
Use the unified Python binding built on the installed opaque C SDK.Maelys DL exposes a native Python binding for in-process use — no browser or
WASM runtime. It targets research pipelines, offline analysis, and long-running
services: Python exceptions represent errors, explicit objects represent policy
and session lifetimes, and integer terms retain the full native int64 range.
The underlying concepts — EDB facts, predicate kinds, solving and querying — are the same as in C and WASM, documented in the Concepts pages. See Bindings overview for the cross-language map.
These topic pages preserve the progression of the historical Python V1 reference: installation, loading, domains, ownership, term encoding, queries, explanations, and the API reference. They add manifest loading, prepared sessions, Why-false and explicit execution requirements.
The ordinary sequence remains: declare the domain, load a policy, collect facts, solve, and inspect the result. The CFFI extension calls the opaque C facade, never an internal engine structure.
Public Python objects
Python exposes classes and value objects rather than C handles and free functions. The links lead directly to each class card or flag reference. The complete method index below is organized by class. Each topic page lists only the reference cards defined there.
| Object / constant | Role |
|---|---|
Capability | Bitmask of backend capabilities required when preparing a session. |
Diagnostic | Immutable native detail record, separate from the operation status. |
Edb | Owns the mutable input facts for one complete request. |
Engine | Owns Python wrappers and their native children; registers and loads domains. |
ExplanationKind | Bitmask selecting optional reusable explanation storage. |
Limits | Immutable snapshot of native build capacities. |
MaelysDatalogError | Exception carrying a native status and structured diagnostic. |
Predicate | Immutable declaration of one predicate; construction does not register it. |
PRED_EDB | Integer predicate-role flag; not a method or class. |
PRED_IDB | Integer predicate-role flag; not a method or class. |
PRED_POLICY_FACT | Integer predicate-role flag; not a method or class. |
PRED_QUERY | Integer predicate-role flag; not a method or class. |
Ruleset | Owns one loaded policy set and creates input buffers and sessions. |
ResultTerm | Immutable raw term tied to its live result. |
Session | Reuses one selected policy with at most one live result. |
SolveResult | Retains a solved model for queries, enumeration and explanations. |
Status | Named native operation statuses, used when handling exceptions. |
The binding also exports Status, Capability, ExplanationKind and the PRED_* flags. Predicate roles are explained in Registries, capability and explanation masks in Solving and Querying, and native failure statuses in Errors.
Main methods by task
| Task | Python entry points |
|---|---|
| Register vocabulary | Engine.register_domain() |
| Load policy | Engine.load_inline_ruleset(), Engine.load_manifest() |
| Build input | Ruleset.edb(), Edb.add_fact(), Edb.add_facts() |
| Solve | Ruleset.solve(), Ruleset.prepare(), Session.solve() |
| Inspect answers | SolveResult.contains_fact(), enumerate_predicate_facts(), enumerate_raw() |
| Explain answers | SolveResult.explain_true(), explain_false() |
| Handle failure | error.diagnostic and Python shape exceptions |
Explore by topic
- Registries
- Loading & manifests
- Rulesets
- Runtime EDB
- Solving
- Querying and explanations
- Errors
- Complete examples
API reference
This is the complete cross-topic method index, grouped by its Python class. Each link opens the method's single reference card. On the topic pages, the tables are local indexes rather than complete class inventories.
Edb methods
| Method | Purpose |
|---|---|
Edb.add_fact() | Append one fact |
Edb.add_facts() | Append an atomic batch |
Edb.clear() | Discard an unsolved batch |
Edb.close() | Release input storage |
Edb.reset() | Start a new request in the same buffer |
Engine methods
| Method | Purpose |
|---|---|
Engine.close() | Close the Python owner |
Engine.load_inline_ruleset() | Load a policy from text |
Engine.load_manifest() | Load verified policy files |
Engine.register_domain() | Register the complete domain vocabulary before loading policies. |
Predicate methods
| Method | Purpose |
|---|---|
Predicate.edb() | Create a runtime-input declaration with PRED_EDB. |
Predicate.edb_query() | Create a runtime-input declaration with query permission. |
Predicate.idb() | Create a derived-fact declaration with PRED_IDB. |
Predicate.idb_query() | Create a derived-fact declaration with query permission. |
Predicate.policy_fact() | Create a policy-source fact declaration. |
Predicate.policy_fact_query() | Create a policy-source fact declaration with query permission. |
ResultTerm methods
| Method | Purpose |
|---|---|
ResultTerm.resolve() | Resolve this raw term |
Ruleset methods
| Method | Purpose |
|---|---|
Ruleset.close() | Release the loaded policy set |
Ruleset.edb() | Create an input buffer |
Ruleset.prepare() | Prepare a reusable session |
Ruleset.solve() | Solve with a private session |
Session methods
| Method | Purpose |
|---|---|
Session.close() | Release a prepared session |
Session.solve() | Evaluate one complete request |
SolveResult methods
| Method | Purpose |
|---|---|
SolveResult.close() | Release a solved result |
SolveResult.contains_fact() | Check one concrete fact |
SolveResult.derived_fact_count() | Count the derived facts |
SolveResult.enumerate_predicate_facts() | Copy resolved result rows |
SolveResult.enumerate_raw() | Read result-owned term views |
SolveResult.explain_false() | Explain an absent fact |
SolveResult.explain_true() | Explain a derived fact |
SolveResult.resolve_term() | Resolve a term through its owner |
Constructors
Class construction is separate from domain registration or solving.
| Constructor | Result | Purpose |
|---|---|---|
Engine() | Engine | Create the Python owner; no domain is registered yet. |
Predicate(name, arity, flags) | Predicate | Build a declaration; registration validates it later. |
Properties and protocols
These are read-only attributes or Python protocols, not additional engine calls. Class-card links explain their types and ownership.
| Property / protocol | Type | Meaning |
|---|---|---|
engine.limits | Limits | Immutable snapshot of build capacities. |
ruleset.policy_count | int | Number of loaded policies; valid indices run from 0 to count minus 1. |
ruleset.fingerprint | str | Identity of the whole loaded policy set, not a hash of request facts. |
len(edb) | int | Number of stored entries before deduplication; Python length protocol, not an Edb.count() method. |
session.fingerprint | str | Selected-policy authority. |
session.execution_fingerprint | str | Selected policy plus backend and execution configuration. |
result.fingerprint | str | Selected-policy authority. |
result.execution_fingerprint | str | Selected policy plus execution configuration. |
term.kind | str | symbol, integer or boolean. |
term.value | int | bool | Result-local symbol ID, integer or boolean according to kind. |
error.status | int | Native operation status; compare with Status members. |
error.code | int | Alias of error.status; not the diagnostic reason code. |
error.message | str | Human-readable detail. |
error.hint | str | Suggested corrective action when supplied. |
error.diagnostic | Diagnostic | Structured native detail, including its distinct reason code and presence mask. |
Installation
Python 3.10 or later, CFFI, setuptools, a C11 compiler and CMake 3.16+ are required. The binding lives in the engine repository, not on a package registry. Install the native SDK first, then compile the CFFI extension against that same SDK prefix. From the v0.11.1 source checkout:
python3 -m venv build/python-venv
build/python-venv/bin/python -m pip install cffi setuptools pytest
cmake -S . -B build/cmake-small
cmake --build build/cmake-small --parallel 4
cmake --install build/cmake-small --prefix "$PWD/build/sdk-small"
build/python-venv/bin/python bindings/python/build_cffi.py \
--sdk-prefix "$PWD/build/sdk-small"
MAELYS_DATALOG_SDK_PREFIX="$PWD/build/sdk-small" \
MAELYS_DATALOG_EXPECT_PROFILE=small PYTHONPATH=bindings/python \
build/python-venv/bin/python -m pytest -q tests/pythonThe builder copies the installed shared library beside the Python extension.
--build-dir and --engine-dir belonged to the experiment and are removed;
--sdk-prefix proves that headers and library come from one installation.
For LARGE, configure a separate build with
-DMAELYS_DATALOG_PROFILE_LARGE=ON, install it to a separate prefix, rebuild
the extension and start a fresh interpreter. Inspect engine.limits and the
expected-profile check to verify which library was actually loaded.
Native library discovery
A compiled Python extension is itself a dynamic library. It depends on
libmaelys_datalog_shared (the engine), not the historical
libmaelys_py_bind shim. At import time, the operating system must find that
dependency; PYTHONPATH alone only helps Python find its package.
The CFFI builder copies the selected engine library beside the package. On Linux the resulting layout is:
bindings/python/maelys_datalog/
├── __init__.py
├── engine.py
├── _maelys_cffi....so ← compiled extension
└── libmaelys_datalog_shared.so ← copied from --sdk-prefixOn macOS the native library uses .dylib; the extension still uses Python's
platform-specific suffix. Python first finds maelys_datalog through its
module path, then the OS resolves the extension's native dependency.
$ORIGIN on Linux and @loader_path on macOS record a loader-relative
search path: look beside the extension, not in the original build directory.
Keep the extension and library together. No LD_LIBRARY_PATH is needed for
this layout; if import fails, check their origin and profile before changing
environment variables.
# check_import.py
import maelys_datalog
print("import OK")PYTHONPATH=/path/to/maelys-datalog/bindings/python python check_import.pyNative call path
The loading choices above are separate from the library layers below. Both supported choices cross the same public C boundary:
- Python APImaelys_datalog
- CFFI extensiongenerated bridge
- Opaque C facademaelys/datalog.h
- Native enginesame inference semantics
There is no handwritten maelys_py_bind layer in this path. CFFI still
generates a native extension; that extension links to
libmaelys_datalog_shared. Opaque handles hide implementation details,
not features: engine.limits and result.derived_fact_count() are exposed
through public C getters.
Both inline source and native manifests are supported. load_inline_ruleset()
compiles one policy; load_manifest() preserves native SHA verification,
query whitelists and multi-policy selection. The generated bridge includes
only <maelys/datalog.h>, never backend descriptors or private engine
structures.
One consumer header, a separate extension API
datalog.h supplies the opaque handles, capability constants, session
configuration and execution fingerprint used by this binding.
datalog_backend.h is for C developers implementing an alternative execution
backend; Python does not include it.
When you call ruleset.prepare(required_capabilities=..., work_limit=...),
the binding creates an opaque native configuration, sets those values, creates
the session and frees the temporary configuration. Session creation copies the
values, so the session does not depend on that configuration's lifetime.
Python code never supplies a backend ABI version or a C structure size.
This changes the native integration, not the Python method signatures. Unsupported requirements still fail explicitly: in particular, the reference backend rejects a nonzero work budget rather than pretending to enforce it.
Session lifecycle
with Engine() as enginePython owner of policy wrappers and an immutable snapshot of native build limits.
engine.register_domain(...)Declare the vocabulary in the process-wide registry; it is not owned by an Engine.
engine.load_inline_ruleset(...)Own an opaque compiled-policy handle, not an exposed C struct or mutable symbol table.
edb.add_fact(...) / edb.add_facts(...)Own copied input values in an opaque native EDB; additions check storage bounds without running the solver.
result = ruleset.solve(edb)Each solve creates a native session, submits one complete batch and retains the result for queries.
result.close() or leaving with Engine()Release each result before its session, then release its policy. Closing Engine closes remaining native children.
Ruleset owns an opaque compiled-policy handle — not Engine, which owns the Python wrappers and the build-limit snapshot. Multiple rulesets can be loaded and closed independently within a process. Unlike those handles, the domain registry remains shared and survives Engine closure.
Each successful ruleset.solve() creates its own opaque session and result.
The diagram above describes this convenience path. For repeated evaluation,
ruleset.prepare() returns an explicit reusable Session with one live result
at a time; see prepared sessions.
Several results can coexist. After success, the EDB cannot be mutated,
but it can be solved again to produce another independent result.
Use a new ruleset.edb() for different inputs.
result.close() frees its result and, for the convenience path, its private
session. With an explicit Session, closing the result leaves the session reusable.
session.close() closes its live result first. ruleset.close() closes all
results, sessions and EDBs before releasing the policy. The with Engine()
block closes all remaining children even on exceptions. Objects used
after their parent closes raise RuntimeError; query results must be
read while their owner remains open.
The Python Edb owns an opaque native input EDB handle. Its idempotent
close() frees that storage; it does not invalidate an already computed result.
Closing its Ruleset prevents later use. Close buffers you no longer need
to release native input memory, especially in long-running processes.
Python API walkthrough
Five owner/value layers structure this walkthrough: Engine owns Python
Rulesets and reads build limits; Ruleset owns a compiled policy set; Edb
buffers one evaluation's inputs; Session holds prepared native execution
state; SolveResult retains one solve's model. The convenience solve()
creates its Session for you. The sequence is the same as the original
reference, but insertion timing and symbol ownership change.
Threading model
The old binding used a process-wide registry lock plus per-Ruleset symbol
locks. Those locks protected particular operations; they did not make every
object safe to share. Python makes the ownership rule explicit instead:
an Engine and all its handles are confined to the thread that created it.
The wrapper checks this before entering native code, including on close().
Do not rely on the Python interpreter lock to serialize access to native objects, and do not move an Engine into a worker after creating it in the main thread. Create the Engine inside that worker. Independent workers can own independent Engines and rulesets, while domain registration and policy loading remain serialized by the wrapper's process-wide registry lock.
That lock covers calls made through this binding. It is not a cross-language synchronization protocol for arbitrary C code mutating the same registry. Creating another Engine also does not create or unregister a domain: the vocabulary remains process-wide, as described under Domain definition.
Within one owner thread, a prepared Session permits one live result. Close that result before solving again. Independent convenience solves create independent sessions and may keep several results open. These are lifecycle rules, not an invitation to use the handles concurrently on other threads.
Use context managers or explicit close(): the prototype has no GC finalizer.
A parent closes native children in result → session → EDB → policy order, and later
use of a closed child raises RuntimeError. A second close() on a wrapper
is harmless on its owner thread; it is not permission to race close with use.
Engine feature coverage
The following coverage is verified against the current code, not inferred from a roadmap. This is the sole source binding shipped with the current engine; it is not a separately published Python package.
| Capability | Python |
|---|---|
| Standard language, including negation, positive disjunction and wildcard | Policy source |
Standard ground filters starts_with, ends_with, contains | Policy source; visible in native explanations |
| Manifest SHA checks, query whitelist, policy-local vocabulary | engine.load_manifest() |
| Multiple policies | ruleset.policy_count, policy_index |
| Reusable prepared state | ruleset.prepare() |
| Build limits and total derived count | engine.limits, result.derived_fact_count() |
| Resolved and result-owned raw enumeration | enumerate_predicate_facts(), enumerate_raw() |
| Why-true / Why-false | explain_true(), explain_false() |
| Policy and execution identity | Distinct fingerprint properties |
| Full native diagnostics | error.diagnostic |
| Explicit capability requirements | required_capabilities; rejection without fallback |
| Work-budget request | Exposed, but rejected by the current reference backend |
| True delta/incremental inference | Not implemented by the current engine API |
| Custom native frontend/backend/planner/filter registrations and IR builders | Separate extension SDK; no Python callback bridge here |
Opaque handles do not preclude extensions. However, registering native callbacks is a different API from using the engine's standard filters: this binding does not manufacture a dynamic loader or Python callback ABI.
Migration from the deprecated Python V1 binding
| Surface | Deprecated 0.9.1 Python V1 | Current maelys_datalog |
|---|---|---|
| Native boundary | Python-specific C shim | Public opaque C facade |
add_fact() | Native insertion | Atomic native opaque-buffer insertion |
add_facts() | Not available | Atomic native batch append; complete evaluation at solve |
| Native input errors | Usually at insertion | Storage checks at addition; policy-specific checks at solve |
| Declaring text constants for inline policies | No Python atoms parameter | register_domain(..., atoms=[...]) |
| Input symbol IDs | Ruleset-scoped intern_symbol | Not accepted; use strings |
| Raw enumeration | Available | Result-owned, lifetime-checked views |
| Why-true | explain_fact_text() | explain_true() |
| Why-false | Not wrapped | explain_false() |
| Manifest loading | Not wrapped | load_manifest() |
| Prepared-session reuse | Different legacy lifecycle | Explicit prepare() |
| Native diagnostic detail | Binding-specific errors | Immutable complete Diagnostic |
The deprecated binding's missing atoms parameter does not mean that its engine
cannot handle constants: that Python registration method simply does not expose
the vocabulary parameter. Python makes it explicit, as explained under
domain definition.
The old binding is not shipped in 0.10.0. Keeping the same import is not a complete migration: review buffering, symbol ownership, method names and lifetimes.
Lifecycle and configuration notes
Engine and domain
Engine() / engine.limits— Create a thread-confined Python owner and read the loaded native capacities.Predicate(name, arity, flags)— Declare a relation; combine kinds using PRED_EDB, PRED_IDB, PRED_QUERY and PRED_POLICY_FACT.engine.register_domain(name, predicates, atoms=())— Register process-wide predicate and atom vocabulary. Identical re-registration is a no-op; conflicting vocabulary raises.engine.load_inline_ruleset(domain, policy_id, source)— Compile source against a registered domain; return a Ruleset. No manifest query whitelist.engine.load_manifest(path, allow_test_only=False, allow_undeclared_policy_atoms=False)— Load a manifest file and SHA-verified policies with their query declarations. Extra admission flags are explicit opt-ins.engine.close() / with Engine()— Close remaining native children; safe to close again on the owner thread. Does not unregister domains.
Ruleset and EDB buffer
ruleset.policy_count / ruleset.fingerprint— Read policy-set metadata; evaluation selects a zero-based policy_index.ruleset.edb()— Return a fresh owned native input buffer, tracked by this Ruleset.edb.add_fact(predicate, terms)— Atomically copy one fact into C-owned storage; check storage bounds immediately.edb.add_facts(iterable)— Stage a bounded iterable, then atomically append its facts in C. No partial append on iteration or native storage failure.len(edb) / edb.clear()— Count stored entries before deduplication / discard an unsolved buffer; clear is rejected after successful solve.edb.close()— Free native input storage and prohibit further use; existing results are unaffected. Idempotent.ruleset.solve(edb, policy_index=0, required_capabilities=0, work_limit=0)— Submit a complete batch with a private Session owned by the result. Native rejection publishes no partial result.ruleset.prepare(policy_index=0, required_capabilities=0, work_limit=0)— Create reusable prepared state using the opaque session configuration API.ruleset.close() / with ruleset— Close results, sessions, input EDBs and policy. All children become unusable.edb.reset()— Explicitly empty and unfreeze the input buffer for a new request; does not close a live result.
Prepared Session
session.solve(edb)— Solve one full batch. Close the previous result first; this is not delta/incremental inference.session.fingerprint— Identity of selected policy authority.session.execution_fingerprint— Identity also binding backend, execution requirements/budget setting and size profile.Capability / required_capabilities— Add explicit required capabilities; unsupported requirements are rejected without fallback.work_limit— A uint64 cooperative-work request, not a deadline. Nonzero values are rejected by the current reference backend.session.close() / with session— Close its live result then the session; no effect on independent sessions.ExplanationKind— Select optional shared explanation storage when preparing a session; this is distinct from capability requirements.
SolveResult and raw terms
result.contains_fact(predicate, terms)— Check a ground fact under query permissions; searches retained policy/EDB/IDB facts.result.enumerate_predicate_facts(predicate, arity)— Copy queryable derived IDB rows to plain Python tuples; copies remain usable after close.result.enumerate_raw(predicate, arity)— Return the same derived rows as immutable, result-owned ResultTerm views.term.kind / term.value— Inspect the raw kind/value. Symbol IDs are local to one result, not reusable inputs.term.resolve() / result.resolve_term(term)— Resolve through the owning live result only; cross-result resolution is rejected.result.explain_true(predicate, terms)— Native Why-true text including complete/not-derived/truncated status; raises on native errors.result.explain_false(predicate, terms)— Native bounded Why-false text; completeness is not a formal absence certificate.result.derived_fact_count()— Count all distinct derived IDB facts, including non-queryable intermediates.result.fingerprint / result.execution_fingerprint— The policy and execution identities of the owning Session.result.close() / with result— Free retained result state, plus the private Session for convenience solves. Raw resolution becomes invalid.
Exceptions
MaelysDatalogError.status / .code— Native facade return status; code is an alias, not the diagnostic reason.error.message / error.hint— Human-readable detail, not a stable grammar to parse.error.diagnostic— Immutable source/code/phase/line/column/message/hint record. Fields are empty/zero when unavailable.TypeError / ValueError / OverflowError— Caller representation errors, distinct from native solve rejection.RuntimeError— Lifecycle misuse such as wrong thread, closed owner or reusing a Session with a live result.