API Reference

Bindings

Maelys DL is a C engine. Two language bindings expose it outside C, compiled from the same engine source with no shared runtime state between them — a process using one does not load the other.

BindingTargetReference
WASM/JSBrowser, NodeWASM bindings
Python (cffi)Native processPython bindings

The underlying concepts — EDB facts, predicate kinds, solving, querying — are identical in both. They are documented once, at the C level:

ConceptReference
Predicate kinds, domain registration, vocabularyRegistries
EDB facts, term types, atom allowlistRuntime EDB
Solving, evaluation model, fail-closedSolving
Ground queries, whitelist enforcementQuerying
Manifest, SHA verification, loading modesManifest Loading

This page covers only what differs between the two bindings and why. Each binding's own page covers its specific API, build, and lifecycle.

Where each fits

Enginebrowser / NodeWASMJS wrappernative processC shimcffi extensionPython package

WASM targets the browser: a sandboxed 32-bit ABI, one JavaScript runtime, no native threads. Python targets everything else that wants to embed the engine natively — a research pipeline generating thousands of EDB facts per second, a notebook doing iterative policy development, a long-running process evaluating many requests without a JavaScript runtime in the loop. Being native rather than sandboxed in a WASM32 ABI removes two constraints that shape the WASM binding — Session model and Errors, detailed in their own sections below:

WASM bindingsPython bindings
Integer termsSplit into lo/hi 32-bit words — WASM32 has no 64-bit ABI typePassed as a native int64_t — Python integers are already arbitrary precision
Session modelOne implicit session per module instance (s_solve_result, s_edb_state)Explicit object handles — Engine, Ruleset, Edb, SolveResult, each independently constructible
ErrorsSentinel return values (-1), read via diagMessage/diagCodePython exceptions (MaelysDatalogError and subclasses), raised directly
Derived IDB enumerationenumeratePredicateFacts returns discriminated terms; symbols remain ids and resolve separately through symbolTextenumerate_predicate_facts returns resolved tuples; enumerate_predicate_facts_raw preserves symbol ids
ConcurrencySingle-threaded by construction (one JS runtime)Real OS threads — per-ruleset locking serializes symbol-table paths and add_fact; concurrent solves on a shared ruleset are not a documented guarantee

Session model

s_solve_result/s_edb_state are static variables inside the WASM C file — at most one current ruleset/EDB/result per module instance. This is not truly global, though: every MaelysPlayground.create() call instantiates a new WASM module, with its own linear memory and its own copies of those statics — WASM bindings states this directly ("One domain per module instance ... create a new MaelysPlayground instance"). It is a singleton per instance, not a true process-wide global.

The reason for this shape, rather than handles, is what a handle would have to be in JavaScript: a raw pointer exposed to JS is just a number — nothing stops it from being reused after the underlying memory is freed, or passed to the wrong function. Keeping at most one live object of each type inside the module sidesteps that risk entirely: JS never holds a pointer, only method calls.

cffi does not have that problem the same way. It returns a real C pointer wrapped in a cdata object, which the Python class (Ruleset, Edb, SolveResult) wraps in turn — Python's own type system (a Ruleset instance is never confused with an Edb instance) plus explicit close()/ __del__ give the safety that WASM achieves instead by restricting itself to one object per module instance. That is why several Ruleset objects can coexist naturally in one Python process, where the WASM equivalent would require creating several separate module instances.

Errors

A sentinel is a reserved value inside the normal return channel that means "error" instead of a real result — the WASM export behind derivedFactCount returns an int32_t that is either the real derived-fact count (>= 0) or -1 for "no valid solve / underlying failure" (the JS wrapper then turns that -1 into a thrown error). One channel carries both, because a real count is never negative.

This is not a hard technical requirement of WASM — Emscripten's ccall can also write a result through an out-param pointer, exactly like the native C API does (internRuntimeSymbol writes the id to a pointer and returns a status code separately). The sentinel is an ergonomic choice for functions where the result and the error can share one value space without ambiguity: one ccall, no allocate/read/free dance for the common case. And the -1 never reaches the JS caller as a bare number either — MaelysPlayground checks it and throws a real JS exception, fetching the message through a second call to diagMessage().

The deeper reason the two bindings differ here: the native C API already uses an out-param, not a sentinelmaelys_datalog_solve_result_derived_fact_count(result, size_t *out_count) returns a maelys_result_t status code and writes the count separately. It is WASM that departs from this shape, specifically for ccall convenience. The Python shim has no reason to depart from it: cffi calls a C function with an out-param directly (ffi.new("size_t *"), read back after the call) without the allocate/marshal/free cost that ccall would pay for the same pattern. So maelys_py_result_derived_fact_count keeps the native status-code-plus-out-param shape unchanged, and Python's SolveResult turns the status code into a Python exception one layer up — no sentinel value was ever needed in between.

i

Both bindings wrap the same C engine and the same fail-closed semantics — they differ in the surface a native language needs, not in the underlying policy model.

Choosing one

SituationBinding
Browser UI, playground, or any page running in a user's tabWASM
Node.js service that also needs to run in a browserWASM
Research pipeline, notebook, offline analysis scriptPython
Long-running native service, no browser or Node runtime involvedPython
Need the full int64 range for integer termsPython