The C ABI¶
zelph is a C++ library with a Janet host on top. The C ABI is the third way in: a narrow extern "C" surface over the graph and its compiled networks, so a program written in Rust, C, Go, Python or anything else with an FFI can drive zelph directly, without the Janet interpreter in between.
It is deliberately small. Everything that is naturally expressed as a script — rules, .import, display schemes, SPARQL — stays in Janet and the REPL. What the C ABI covers is the part a host application needs: resolve names to nodes, assert facts, read them back, compile a network out of the graph, evaluate it, train it, and persist the result.
The header is zelph/capi/zelph_c.h. Link against libzelph.
Conventions¶
Every function follows the same five rules, so there is nothing per-function to remember:
| Status, not exceptions | Every function returns an int32_t status (ZELPH_OK, ZELPH_INVALID_ARGUMENT, ZELPH_BUFFER_TOO_SMALL, ZELPH_RUNTIME_ERROR). Results come back through out-parameters. No C++ exception ever crosses the boundary. |
| One message per thread | On failure, zelph_last_error() returns the reason. It is thread-local and borrowed — valid until the next zelph_* call on that thread. A successful call clears it. |
Nodes are uint64_t |
A node is its hash, so it is stable across calls and across a save/load cycle: resolving the same name in a freshly loaded graph yields the same number. 0 is not a node. |
| Strings are UTF-8 | Strings passed in are borrowed for the duration of the call. Strings handed out are owned by the caller and released with zelph_string_free(). |
| Caller owns array memory | Arrays are written into a caller-supplied buffer. The count parameter is in/out: on entry the capacity, on return the number of elements produced. If the buffer is too small, nothing is written, count holds what is needed and the call returns ZELPH_BUFFER_TOO_SMALL — so passing a null buffer with capacity 0 asks for the size. |
Lifetime and threading¶
int32_t zelph_engine_create(zelph_output_fn output, void* user_data, zelph_engine** out_engine);
void zelph_engine_destroy(zelph_engine* engine);
One engine per process. The Janet script engine keeps a process-wide instance pointer for its C functions, so a second engine would silently redirect the first one's calls; zelph_engine_create refuses with ZELPH_RUNTIME_ERROR while another engine is alive. Destroying the engine invalidates every network handle taken from it, as does a load operation that replaces the graph.
output may be null, in which case the engine writes to the process's standard streams as the zelph binary does. Otherwise every line the engine emits is handed to the callback with its channel (ZELPH_CHANNEL_OUT, ZELPH_CHANNEL_ERROR, ZELPH_CHANNEL_DIAGNOSTIC, ZELPH_CHANNEL_PROMPT) — which is what a host with its own protocol on stdout, such as a game engine speaking to a GUI, needs.
zelph_nn_eval_nodes is safe to call from any number of threads at once, including while another thread is training the same network. That guarantee comes from NeuralNet itself and is the reason a compiled net can serve a parallel search. zelph_nn_write_back is also permissible on any thread: it modifies solely the weights of the net’s own synapses, under the lock of the graph’s weight store, and creates nothing. zelph_save reads the weights under the same lock, whereas zelph_load replaces the graph without taking it, thus a write-back must not run while the engine’s thread performs the load.
The graph is tied to the thread responsible for creating the engine. Any attempt to modify it, run the engine, or alter its state from a different thread results in a ZELPH_RUNTIME_ERROR, with zelph_last_error() identifying the function and saying "main thread only": zelph_fact, zelph_refute, zelph_mention, zelph_list, zelph_set, zelph_collection, zelph_negate, zelph_variable, zelph_clear_variables, zelph_rule, the three run functions, zelph_set_parallel, zelph_query, the four cluster calls, zelph_load, zelph_save, zelph_nn_compile, and zelph_nn_connect_layers – exactly as the corresponding Janet functions are refused on a Janet thread. On a different thread, zelph_resolve carries out nothing but a name lookup, and a name lacking a corresponding node is rejected with the message "zelph_resolve: 'zelph_fact_parts, zelph_list_elements, zelph_name, zelph_sources, zelph_targets, zelph_exists) is permitted. zelph_engine_destroy cannot reject anything: invoke it on the thread that created the engine.
The graph¶
| Function | |
|---|---|
zelph_resolve(engine, name, lang, out_node) |
Name to node, creating it if needed. lang may be null for the current language. When operating on a thread distinct from the engine’s, a name without an associated node is refused. |
zelph_fact(engine, subject, predicate, objects, object_count, out_fact) |
Create the fact (subject predicate object...) and return its node. The invocation constitutes an assertion, as zelph/fact is in a Janet block: a statement that a rule has recorded solely as its pattern, like the ground condition of a rule that has been loaded, transforms into data, and likewise does such a pattern nested in it. The marking (F ~ refuted) applied to a ground fact F is refused with ZELPH_RUNTIME_ERROR: it constitutes a refutation, which zelph_refute writes. A rule condition that is such a marking is written with zelph_mention, which records it without claiming it. Similarly, a fact that holds a rule’s marking pattern of a fact the graph claims is refused, since claiming the marking would refute that fact. A => fact whose consequence writes a fact with a predicate that no condition binds, which zelph_rule refuses, is not refused by the invocation, because a generator’s inner rule maintains that shape until the generator holds it: the next zelph_run, zelph_run_once, zelph_run_delta, or zelph_save checks it first, and if it remains a rule in force at that point, eliminates it and fails with ZELPH_RUNTIME_ERROR without running or writing the file, zelph_last_error() beginning with zelph_fact:. The following run or save proceeds. Once the invocation claims it into force, a rule authored by zelph_mention undergoes verification in identical fashion, and its rejection retracts only the claim: the rule stays a mention. |
zelph_refute(engine, subject, predicate, objects, object_count, out_fact) |
The claim that the fact does not hold, as expressed by ¬(F) on its own line and zelph/refute in Janet: the fact yields no response to any subsequent query, and asserting it is refused. A fact that the graph claims is refused with ZELPH_RUNTIME_ERROR ("this fact is known to be true"); a statement that a rule merely wrote down as its pattern may be refuted and stays a pattern; a fact generated by the call is written as a mention, as zelph_mention writes one, ensuring it does not answer any query after the marking is removed. Attempting to refute a fact that has already been refuted results in no change. The marking (F ~ refuted) cannot be refuted in turn and is refused with ZELPH_RUNTIME_ERROR: a refutation is withdrawn by removing its marking. Returns the node associated with the fact. |
zelph_mention(engine, subject, predicate, objects, object_count, out_fact) |
Write the fact (subject predicate object...) without claiming it, and return its node: a mention, as zelph/mention writes one in Janet. A newly recorded fact is marked as written down, not asserted, meaning neither zelph_exists nor a query nor a rule condition takes it as data until a claim of the identical statement – zelph_fact, a firing – makes it data; a fact that already existed retains its original status. zelph_fact using a predicate declared opaque (P ~ opaque, refer to Mentioning a Statement: Opaque Predicates) claims the fact and none of its arguments, which is how a mention written here is held. A rule written in this way – the => fact over a condition and a consequence – constitutes a mention too: it is not in force until zelph_fact of the identical rule claims it. |
zelph_fact_parts(engine, fact, out_subject, out_predicate, out_objects, count) |
The subject, predicate and objects of a fact – the inverse of zelph_fact. The objects come back unordered, because a fact’s objects are a set. |
zelph_list(engine, elements, count, out_node) |
Cons list; the first element becomes the outermost cell. An empty list is the nil node. |
zelph_list_elements(engine, list, out_nodes, count) |
The elements of a cons list, in order – the inverse of zelph_list. nil has none; a node that is not a list is an error, not an empty answer. |
zelph_name(engine, node, lang, out_name) |
The node's name, or null when it has none — an answer, not an error. Free with zelph_string_free. |
zelph_sources(engine, predicate, target, out_nodes, count) |
Every subject of a fact (X predicate target). Directional: (target predicate X) does not contribute. |
zelph_load(engine, path) |
As with the .load command, format detection is included: a saved network replaces the graph, while a dump is incorporated into it, or read from its .bin cache if the graph is empty. A load operation that replaces the graph discards the variables zelph_variable associated by name and invalidates all net handles; a node handle retained by the caller continues to reference whatever carries the identical identifier afterwards, or else refers to nothing. |
zelph_save(engine, path) |
As the .save command. The path must end in .bin. A => fact written by zelph_fact undergoes inspection first, just as before a run (see zelph_fact). |
Structural identity is the property to build on: zelph_list over the same nodes returns the same node, because the graph interns structurally identical subgraphs. Two callers that describe the same structure arrive at the same identifier without agreeing on one.
And it survives the file. Because a node is its hash, describing the same structure again in a freshly loaded graph arrives at the node the file talks about, so a caller that saved facts about <a b c> finds them again without having stored an id anywhere – the structure is the key. zelph_list_elements and zelph_fact_parts close the loop by reading a structure back, which is what a program needs to use one as a stored identifier rather than only as a lookup key. Everything the ABI can build it can also take apart, including what the ABI did not build: a cons cell is the fact (car cons cdr), so the two readers are one mechanism seen twice.
Networks¶
| Function | |
|---|---|
zelph_nn_compile(engine, layers, layer_count, activation, out_handle) |
Compile a feed-forward view of the sub-graph spanned by the layer nodes, input first, output last. activation applies to the hidden layers; the output layer is always linear. |
zelph_nn_connect_layers(engine, from, to, scale, seed, out_created) |
Fully connect two layers with raw synapses drawn from [-scale, scale]. Existing synapses keep their weights, so the call is idempotent and re-wiring never destroys training. |
zelph_nn_eval_nodes(engine, handle, in_nodes, in_activations, in_count, top_k, out_nodes, out_scores, count) |
Forward pass with node-addressed multi-hot input. A null activation array means every listed neuron is 1.0. Results are sorted by descending score, ties by ascending node; top_k < 0 returns the whole output layer. |
zelph_nn_train_nodes(engine, handle, in_nodes, in_activations, in_count, target_nodes, target_activations, target_count, learning_rate, out_loss) |
One SGD step; out_loss is the loss before the update. |
zelph_nn_layer_nodes(engine, handle, layer, out_nodes, count) |
The neurons of one layer in slot order; layer 0 is the input layer. The mapping a slot-addressed caller needs. |
zelph_nn_eval_slots(engine, handle, in_slots, in_activations, in_count, top_k, out_nodes, out_scores, count) |
zelph_nn_eval_nodes with the active inputs named by slot. A slot outside the input layer is an error. |
zelph_nn_train_slots(engine, handle, in_slots, in_activations, in_count, target_nodes, target_activations, target_count, learning_rate, out_loss) |
zelph_nn_train_nodes with the input named by slot; the target stays node-addressed. |
zelph_nn_accumulator_size(engine, handle, out_size) |
How many doubles one accumulator of this network holds. |
zelph_nn_accumulator_set(engine, handle, slots, activations, count, accumulator, accumulator_size) |
The input layer's pre-activation for an active set. Set then eval is zelph_nn_eval_slots to the bit. |
zelph_nn_accumulator_update(engine, handle, added, added_activations, added_count, removed, removed_activations, removed_count, accumulator, accumulator_size) |
The same vector moved: the removed rows are subtracted before the added ones are added. |
zelph_nn_accumulator_eval(engine, handle, accumulator, accumulator_size, top_k, out_nodes, out_scores, count) |
The layers behind the first, from an accumulator. Sorted and limited as zelph_nn_eval_nodes is. |
zelph_nn_write_back(engine, handle) |
Copy the compiled net's weights into the graph's edge-weight store — required before zelph_save, or what is persisted is the untrained graph. |
zelph_nn_snapshot_shape(engine, handle, out_sizes, count) |
One element count per weight matrix. |
zelph_nn_snapshot(engine, handle, out_weights, count) |
The weights, matrices concatenated in layer order. One matrix is row-major by post-synaptic unit: input i to unit j is at j * n_pre + i. |
zelph_nn_restore(engine, handle, weights, weight_count, sizes, size_count) |
Put a snapshot back. The shapes must match. |
The node-addressed entry points are the ones that matter for a sparse input layer: the input is the list of active neurons, so a 768-input encoding with 32 pieces on the board costs 32 terms, not 768.
A caller that evaluates the same layer millions of times can go one step further and name the active neurons by their slot in the input layer rather than by their node. That skips a hash lookup per active neuron, which on a small network is the largest single item an evaluation has left – 0.17 of 0.43 microseconds for 34 active inputs of 780. Resolve the mapping once with zelph_nn_layer_nodes after compiling, then pass slots for ever after.
Keeping the first layer between calls¶
An accumulator is the input layer’s pre-activation vector, maintained by the caller and shifted by the difference between one active input set and the next rather than being reconstructed from all of them. Where consecutive queries share most of their active inputs – a search over states that change by a few features per move, or a fixed context scored against many candidates – that transforms the cost of the first layer from O(active) into O(changed), and what remains is the layers behind it.
The buffer is zelph_nn_accumulator_size doubles and belongs to the caller, so it incurs no allocation, no handle and no lock to copy: a search retains one per ply and duplicates the parent’s on the way down. Measured on a 780 × 32 × 1 network with 34 active inputs, two of which a move changes: 0.12 µs for a moved accumulator against 0.32 µs for the same evaluation built from scratch.
Two points to grasp. An accumulator holds value only in relation to the weights it was constructed with, thus a training iteration nullifies each of those. And zelph_nn_accumulator_update is not bit-identical to zelph_nn_accumulator_set when applied to the same active set: adding and subtracting rows introduces rounding disparities compared to summing them in a single operation, and that divergence builds up across a sequence of updates. Set it afresh whenever that becomes relevant.
The hidden-layer activation¶
ZELPH_ACTIVATION_RELU is max(0, x) and is the default, the value 0 of the enum.
ZELPH_ACTIVATION_LEAKY_RELU is max(0.01 x, x).
The difference is not a matter of taste. With a plain ReLU, a hidden layer whose every unit
is negative for every input has an output of exactly 0 and a gradient of exactly 0. No
further training can leave that state — it is absorbing — and a small net trained online can
walk into it and stay there. A leaky unit passes a hundredth of the gradient instead of none,
which is the difference between "slow" and "never"; src/test/test_capi.cpp pins exactly
that, with 500 training steps that move a leaky net and leave a ReLU one bit for bit
unchanged.
The activation is a property of the compiled view, not of the graph: a net trained with one must be evaluated with the same one, or every output that came from a unit below zero changes. Nothing in the file records it, so a caller that uses anything but the default has to record the choice itself — as a fact in the graph, next to whatever else describes how the net was built.
Snapshot and restore exist because training walks past its best point: the criterion that says "stop" can only fire after the fact, so without a way back the weights that get saved are always some epochs late.
A complete example¶
#include <zelph_c.h>
#include <stdio.h>
static void report(void* user, int32_t channel, const char* text, int32_t newline)
{
(void)user;
(void)newline;
if (channel == ZELPH_CHANNEL_ERROR) printf("engine: %s\n", text);
}
#define CHECK(call) \
if ((call) != ZELPH_OK) \
{ \
printf("%s failed: %s\n", #call, zelph_last_error()); \
return 1; \
}
int main(void)
{
zelph_engine* z = NULL;
CHECK(zelph_engine_create(report, NULL, &z))
/* Two neurons in an input layer, two in an output layer. */
zelph_node in = 0, out = 0, part_of = 0;
CHECK(zelph_resolve(z, "In", NULL, &in))
CHECK(zelph_resolve(z, "Out", NULL, &out))
CHECK(zelph_resolve(z, "in", NULL, &part_of))
const char* neurons[4] = {"i1", "i2", "o1", "o2"};
zelph_node node[4];
for (int i = 0; i < 4; ++i)
{
zelph_node fact = 0;
zelph_node layer = i < 2 ? in : out;
CHECK(zelph_resolve(z, neurons[i], NULL, &node[i]))
CHECK(zelph_fact(z, node[i], part_of, &layer, 1, &fact))
}
int64_t created = 0;
CHECK(zelph_nn_connect_layers(z, in, out, 0.0, 1, &created))
printf("synapses created: %lld\n", (long long)created);
const zelph_node layers[2] = {in, out};
zelph_net net = -1;
CHECK(zelph_nn_compile(z, layers, 2, &net))
/* i1 -> o1, i2 -> o2 */
for (int epoch = 0; epoch < 60; ++epoch)
{
CHECK(zelph_nn_train_nodes(z, net, &node[0], NULL, 1, &node[2], NULL, 1, 0.5, NULL))
CHECK(zelph_nn_train_nodes(z, net, &node[1], NULL, 1, &node[3], NULL, 1, 0.5, NULL))
}
for (int i = 0; i < 2; ++i)
{
zelph_node top = 0;
double score = 0;
size_t count = 1;
char* name = NULL;
CHECK(zelph_nn_eval_nodes(z, net, &node[i], NULL, 1, 1, &top, &score, &count))
CHECK(zelph_name(z, top, NULL, &name))
printf("%s -> %s (%.3f)\n", neurons[i], name, score);
zelph_string_free(name);
}
zelph_engine_destroy(z);
return 0;
}
Built against a build-release tree:
gcc -std=c11 example.c \
-I<zelph>/src/lib/capi -I<zelph>/build-release/src/lib \
-L<zelph>/build-release/bin -lzelph \
-Wl,-rpath,<zelph>/build-release/bin \
-o example
synapses created: 4
i1 -> o1 (1.000)
i2 -> o2 (1.000)
Reasoning¶
The graph is not only a store, and this is the surface a program needs to use rules over it.
| Function | |
|---|---|
zelph_variable(engine, name, out_node) |
A variable for a rule or a query pattern. Remembered by name, so asking twice gives the same node - which is what makes a pattern built in one call queryable in another. zelph_clear_variables forgets them |
zelph_set(engine, elements, count, out_node) |
A set constant: identified by its members, so the same elements always yield the same node |
zelph_collection(engine, elements, count, out_node) |
A container with an identity of its own: two calls with the same elements yield two different nodes |
zelph_negate(engine, pattern, out_node) |
The negated condition over a pattern, negation as failure: out_node represents the fact ((pattern) ~ negation), which a rule takes as its condition instead of the pattern itself. Evaluated against the saturated positive fact base, never against in-flight state |
zelph_exists(engine, s, p, objects, n, out_exists) |
Was this fact claimed – asserted or derived? Creates nothing. A fact carrying further objects satisfies it, exactly as a rule condition does; a rule’s own pattern and a refuted fact do not |
zelph_targets(engine, subject, predicate, out_nodes, count) |
The mirror of zelph_sources |
zelph_rule(engine, conditions, n, consequences, m, out) |
When every condition holds, deduce every consequence. The consequences correspond to the objects in a single => fact, meaning the rule fires once per binding, and a fresh variable shared by two of them refers to one node. Returns the condition set. A consequence that attempts to record a fact whose predicate is a variable that no condition binds is refused with ZELPH_RUNTIME_ERROR, and nothing is written. |
zelph_run / zelph_run_once / zelph_run_delta |
Forward chaining: to a fixed point, one pass, or seeded by what was created since the previous run. A => fact authored by zelph_fact is examined first (refer to zelph_fact). |
zelph_set_parallel |
Whether unification may spread a relation's candidates over worker threads. On by default; returns the previous value |
zelph_query(engine, pattern, pairs, pair_count, row_sizes, row_count) |
Answer a pattern. The bindings come back flat - 2n node ids per row, alternating variable and value - with one size per row |
zelph_cluster(engine, name) |
Activate a cluster, or deactivate with a null name. Nodes created while one is active are recorded in it. A name beginning with __ is refused with ZELPH_INVALID_ARGUMENT: the engine’s internal scratch clusters are designated with this prefix |
zelph_cluster_active / _drop / _count |
The active cluster's name; remove everything a cluster recorded and report how many; how large a cluster is |
A query reports the variable as the node the caller created, not as a name, so no string crosses the boundary and no lookup is needed to read an answer.
A rule constructed via the C ABI holds only values. The zelph_rule function accepts its conditions and consequences pre-assembled, thus it is unable to determine which components originated from the rule itself, and therefore marks nothing: a ground fact built for it using zelph_fact is indistinguishable from any other assertion. A ground condition that is to stay a pattern – above all, the marking (F ~ refuted) of a refutation the rule reads, which zelph_fact rejects – is built with zelph_mention, and the rule fires once zelph_refute claims the refutation. A collection instantiated with zelph_collection functions as a value that the rule references, never as a collection of the rule's own, as a literal written in a rule's text is: each firing names that specific node in what it derives, and if one of its members holds a variable, that variable is carried into the data unaltered, with every binding of the rule sharing the same node. A rule requiring collections of its own must be expressed in zelph's language, or in Janet using zelph/rule, whose argument forms execute within the rule's construction phase. A scope in which a C caller constructs a rule's components in the same fashion – mirroring Janet's zelph/build-rule – is follow-up work. For the same rationale, zelph_fact rejects membership in a collection of a rule's own text, or in a rule's condition set, which a caller can reach through a loaded network: a rule's text is immutable after the rule is authored. This encompasses any member already held by the collection. The invocation yields ZELPH_RUNTIME_ERROR, zelph_last_error() identifies the collection, and no data is stored.
Clusters are what make a graph that only grows usable as a working environment. The loop a caller runs is activate, assert, zelph_run_delta, query, deactivate, drop – and what a drop removes is exactly what was created within it, ensuring that a graph loaded from storage is never compromised.
Staying quiet¶
zelph narrates, because its primary front end is a REPL: a line per derived fact on
ZELPH_CHANNEL_OUT, the progress of a reasoning run on ZELPH_CHANNEL_DIAGNOSTIC
("Starting reasoning with 24 worker threads.", a summary, a per-iteration note), decoration on
ZELPH_CHANNEL_PROMPT. Inside another program that is noise, and for a host with its own
protocol on stdout it is a protocol error.
ZELPH_CHANNEL_OUT also carries the occasional line about the results — a run
that filtered deductions says so there rather than on ZELPH_CHANNEL_DIAGNOSTIC,
because a reader who keeps only OUT must not end up with an incomplete
derivation and no sentence saying it is incomplete.
The channel is how you say so. An embedded caller ignores everything except
ZELPH_CHANNEL_ERROR:
static void quiet(void* user, int32_t channel, const char* text, int32_t newline)
{
if (channel != ZELPH_CHANNEL_ERROR) return;
fputs(text, stderr);
if (newline) fputc('\n', stderr);
}
Passing a null callback is not the quiet option — it means "write to the process's standard
streams", which is the REPL's behaviour and the loudest one. Keeping ERROR matters: a caller
that silences that too will debug the next failure blind.
zelph_set_parallel is a throughput/latency choice and not a semantic one: the derived facts
are the same either way. Parallelism pays on a large graph and costs on a small one, where
dispatch dominates the scan it replaces. A caller reasoning about many small fact bases in a
loop is the case that wants it off - and it was reachable from the REPL as .parallel long
before a C caller could touch it.
Rust¶
Two crates in rust/ sit on this ABI and are maintained with it — see The Rust Layer:
zelph-sys |
The raw declarations, generated from zelph_c.h by bindgen at build time, so header and bindings cannot drift. Its build.rs also rebuilds the C++ library on every cargo build — a stale library silently invalidates every measurement taken against it, and nothing in the output says so. ZELPH_BUILD_DIR selects the CMake build directory (default build-release), ZELPH_NO_BUILD skips the CMake step. |
zelph |
The safe wrapper: Engine (resolve, fact, list, elements, name, sources, load, save, compile), Net (best, eval, train, write_back, snapshot, restore), failures as Result<_, zelph::Error> with a kind() to branch on. |
At compile time, the types state what the C ABI refuses at runtime. Engine is neither Send nor Sync, because graph mutation belongs to the thread that created it. Net is both — so a compiled network can be handed to a pool of search threads and evaluated there while another thread trains it, and the compiler checks that rather than a comment.
let z = zelph::Engine::new()?;
let net = z.compile(&[z.resolve("In")?, z.resolve("Out")?])?;
std::thread::scope(|scope| {
for _ in 0..3 {
scope.spawn(|| { net.best(&inputs).unwrap(); }); // &Net crosses threads
}
net.train(&inputs, &targets, 0.05).unwrap(); // while this runs
});
cd rust && cargo test
Note the layers are declared with ordinary facts — (i1 in In) — which is the same statement a .zph script writes as i1 in In. The network is not a separate kind of object; it is a view of the graph, and that is why zelph_save persists it without a network format existing at all.