Skip to content

Scripting with Janet

zelph embeds Janet, a lightweight functional programming language, as its scripting layer. Janet serves as the programmatic backbone behind zelph's syntax: every zelph statement is parsed into a Janet expression before execution. This integration enables users to go beyond zelph's declarative syntax and use loops, conditionals, macros, and data structures to generate facts, rules, and queries programmatically.

Importantly, Janet operates exclusively at input time β€” it generates graph structures that are then processed by zelph's reasoning engine. During inference, only zelph's native engine runs. Think of Janet as a powerful macro system: it constructs the graph, then steps aside. It may decide when the engine runs β€” see Running the engine β€” but not how.

Installing External Packages

zelph ships with an embedded Janet runtime β€” you do not need a separate Janet installation to use Janet scripting in zelph. All built-in Janet functions (including slurp, spit, string/split, and the full standard library) work out of the box.

External Janet packages are only needed for specific functionality such as JSON parsing (spork/json) or advanced CSV handling (spork/csv). These packages are installed via jpm (Janet's package manager), which does require a Janet installation on the host system.

Checking the embedded Janet version

Use the .licenses command to see which Janet version is embedded in your zelph build:

zelph> .licenses
zelph incorporates the following third-party software:
------------------------------------------------------
Janet (v1.41.2) - MIT License
...

Packages installed via jpm should match this major version. In practice, Janet packages remain compatible across minor version differences, but if you encounter unexpected errors, check for a version mismatch first.

Installing jpm

jpm is typically bundled with the Janet distribution:

  • Arch Linux: jpm is part of the janet-lang package (pacman -S janet-lang).
  • macOS (Homebrew): brew install janet includes jpm.
  • Windows (Chocolatey / Scoop): jpm is included with the Janet installation.
  • Other Linux distributions: If your package does not include jpm, see the Janet documentation for manual installation.

Setting up the module path

Janet needs to know where to find installed packages. Set the following environment variables (example for Linux/macOS β€” adapt paths for your system):

export JANET_TREE="$HOME/.local/jpm_tree"
export JANET_PATH="$JANET_TREE/lib:/usr/lib/janet"

Add these to your shell profile (e.g. ~/.bashrc, ~/.zshrc) so they persist across sessions. On Windows, set the corresponding environment variables via the system settings.

Installing packages

To install the spork library (which provides JSON, CSV, and other utilities):

jpm install spork

Once installed, you can use its modules in zelph scripts:

%(use spork/json)
%(pp (decode "{\"name\": \"Alice\", \"age\": 30}"))
@{"age" 30 "name" "Alice"}

Entering Janet Code

There are three ways to write Janet code in zelph:

Inline Prefix %

Prefix a line with % to execute it as Janet:

%(print "Hello from Janet!")

% is a line escape and nothing else. It is recognized only as the first character of a line, and it hands zelph the WHOLE line as Janet code β€” so it can never stand as a term inside a statement:

x knows %(zelph/resolve "Berlin")     # not a statement

To use a value computed in Janet as a term, bind it and unquote its name with ,name (see Referencing Janet Variables in zelph below). Whitespace after % is optional. If the expression spans multiple lines (i.e., has unbalanced delimiters), zelph automatically accumulates subsequent lines until the expression is complete:

%(defn make-facts [items relation target]
   (each item items
     (zelph/fact item relation target)))

All four lines are collected and executed as a single Janet expression.

Block Mode %

A bare % on its own line toggles between zelph mode and Janet mode. In Janet mode, all lines are accumulated and executed together when the block is closed:

%
(def cities ["Berlin" "Paris" "London"])
(def relation "is capital of")
(def countries ["Germany" "France" "England"])

(each i (range (length cities))
  (zelph/fact (cities i) relation (countries i)))
%

This is convenient for longer scripts with multiple definitions and function calls. When closing a Janet block, zelph automatically triggers the reasoning engine (if auto-run is enabled), so any rules created in the block take effect immediately.

Comments and Commands

Lines starting with # (comments) and . (commands like .lang, .run, .save) work identically in both modes. They are never interpreted as Janet or zelph statements.

The zelph API for Janet

zelph registers a set of functions in the Janet environment that mirror zelph's syntactic constructs. These functions operate directly on the semantic network, creating nodes, facts, lists, and sets.

Nodes and Names: zelph/resolve

Every named entity in zelph's graph is a node. The function zelph/resolve takes a string and returns the corresponding node in the current language (as set by .lang), creating it if it does not yet exist:

%(def berlin (zelph/resolve "Berlin"))
%(def germany (zelph/resolve "Germany"))

The returned value is a zelph/node abstract type β€” an opaque handle to the internal node. This is the Janet equivalent of simply writing Berlin in zelph syntax.

When to use zelph/resolve: Whenever you need to refer to a node by name from Janet code. The node is resolved in the currently active language, which matters when working with Wikidata IDs vs. human-readable names.

Facts: zelph/fact

zelph/fact creates a subject–predicate–object triple in the graph and returns the relation node. It accepts three or more arguments (multiple objects create multiple facts with the same subject and predicate):

%(zelph/fact "Berlin" "is capital of" "Germany")

This is equivalent to the zelph statement:

Berlin "is capital of" Germany

String arguments are automatically resolved as node names (identical to zelph/resolve). You can also pass zelph/node values directly:

%(def city (zelph/resolve "Berlin"))
%(zelph/fact city "~" "city")

The function also accepts quoted Janet symbols ('X, '_Var) for zelph variables β€” single uppercase letters or underscore-prefixed identifiers. This is used when building rules and queries (see below).

Programmatic Query Results: zelph/query

When called from Janet, zelph/query returns its results as a Janet array of tables rather than printing them. Each table represents one match, mapping variable symbols to their bound zelph/node values:

%(def results (zelph/query (zelph/fact 'X "is located in" 'Y)))

If the graph contains Berlin "is located in" Germany and Paris "is located in" France, the return value is:

@[@{X <zelph/node 11> Y <zelph/node 13>}
  @{X <zelph/node 14> Y <zelph/node 16>}]

Access individual bindings with get using the same symbol that was passed to zelph/fact:

%(each r results
   (printf "%v is located in %v" (get r 'X) (get r 'Y)))

Iterate over all bindings in a match with eachp:

%(each r results
   (eachp [var node] r
     (printf "  %v = %v" var node)))

Note: The table keys are symbols (e.g. 'X), and the values are zelph/node abstract types β€” opaque handles to the internal graph nodes. This ensures unambiguous identity even when multiple nodes share the same name.

A query pattern is an ordinary node, so it can be kept in a binding and asked repeatedly β€” which is the natural shape for a program that builds its patterns once and queries them per unit of data:

%(def q (zelph/fact 'A "hits" 'B))
%(each r (zelph/query q) (printf "%s -> %s" (zelph/name (get r 'A)) (zelph/name (get r 'B))))
%(each r (zelph/query q) (printf "%s -> %s" (zelph/name (get r 'A)) (zelph/name (get r 'B))))

Both loops print the same matches. The bindings are labelled from the names the variable nodes carry in the graph, so it does not matter whether the pattern was built by the statement that runs the query.

When a query is entered in zelph syntax (not via zelph/query), results are printed to the console β€” zelph/query's return-value behavior only applies when called from Janet code.

Filtering and Transforming Query Results

Since results are standard Janet arrays and tables, all of Janet's collection functions work naturally:

%
(def results (zelph/query (zelph/fact 'X "is located in" 'Y)))

# Extract just the X bindings
(def cities (map (fn [r] (get r 'X)) results))

# Count results
(printf "Found %d matches" (length results))

# Filter: find results where Y is bound to a specific node
(def germany (zelph/resolve "Germany"))
(def in-germany
  (filter (fn [r] (= (get r 'Y) germany))
    results))

(printf "Found %d cities in Germany" (length in-germany))
%

Important: zelph/query is designed for pattern matching with variables. To check whether a specific fact exists, filter the returned bindings directly rather than calling zelph/query with a fully concrete pattern. Note that zelph/fact always creates a fact as a side effect β€” passing concrete nodes to zelph/fact inside a filter would unintentionally add facts to the graph.

Each zelph/query call resets the variable scope, so consecutive queries produce independent results with fresh variable bindings.

Using Query Results in Rules and Facts

Query results can feed back into graph construction:

%
(def german-cities (zelph/query (zelph/fact 'X "is located in" "Germany")))

(each r german-cities
  (zelph/fact (get r 'X) "~" "German city"))
%

Rules in Janet: The let Pattern

In zelph syntax, the focus operator * controls what a parenthesized expression returns. For example, (*{...} ~ conjunction) creates the conjunction fact but returns the set node itself, which is then used as the subject of =>. In Janet, this is achieved naturally using let bindings:

# zelph syntax:
(*{(X "is capital of" Y) (Y "is located in" Z)} ~ conjunction) => (X "is located in" Z)

# Janet equivalent:
%
(let [condition
      (zelph/collection
        (zelph/fact 'X "is capital of" 'Y)
        (zelph/fact 'Y "is located in" 'Z))]
  (zelph/fact condition "~" "conjunction")
  (zelph/fact condition "=>" (zelph/fact 'X "is located in" 'Z)))
%

The let binding stores the set node in condition, then uses it in two separate facts β€” once to mark it as a conjunction, and once to connect it to the consequence via =>. This mirrors exactly what the * operator does in zelph syntax. The reasoning engine is triggered automatically when the Janet block closes (via auto-run).

The Scope of a Variable Symbol: One Block

That let is not only a matter of style. A variable symbol is scoped to one evaluation of a Janet block, exactly as a variable in zelph syntax is quantified by the statement it appears in. Two blocks that both write 'B mean two different variables, so conditions built in separate blocks are not joined by their shared symbol β€” they are multiplied:

zelph> %(zelph/fact "Berlin" "is located in" "Germany")
<zelph/node Berlin "is located in" Germany>
zelph> %(zelph/fact "Lyon" "is located in" "France")
<zelph/node Lyon "is located in" France>
zelph> %(zelph/fact "Germany" "is member of" "EU")
<zelph/node Germany "is member of" EU>
zelph> %(def joined (let [s (zelph/set (zelph/fact 'A "is located in" 'B) (zelph/fact 'B "is member of" 'K))] (zelph/fact s "~" "conjunction") s))
<zelph/node {(B "is member of" K) (A "is located in" B)}>
zelph> %(length (zelph/query joined))
1
zelph> %(def c1 (zelph/fact 'A "is located in" 'B))
<zelph/node A "is located in" B>
zelph> %(def c2 (zelph/fact 'B "is member of" 'K))
<zelph/node B "is member of" K>
zelph> %(def crossed (let [s (zelph/set c1 c2)] (zelph/fact s "~" "conjunction") s))
<zelph/node {(B "is member of" K) (A "is located in" B)}>
zelph> %(length (zelph/query crossed))
2

Both conjunctions print identically β€” the variable nodes carry the same display names β€” and both answer without a warning. Only the counts differ: the first joins on B and reports the one match (Berlin / Germany / EU), the second reports every combination of the two conditions.

The cost of getting this wrong grows with the graph, not with the program. Two conditions of 400 facts each already produce 160,801 rows instead of a handful, and at Wikidata scale the cross product exhausts memory before it finishes. Build all conditions of one pattern inside a single block, which the let form above does naturally. A pattern node may of course be stored and queried later β€” see Programmatic Query Results; it is the symbol, not the node, whose meaning ends with the block.

When one block is not where the program wants to build them β€” a pattern assembled step by step, or by several functions β€” use zelph/var, which returns the variable as an ordinary node. Then the caller's own binding decides how far it reaches:

zelph> %(def B (zelph/var "B"))
<zelph/node B>
zelph> %(def c1 (zelph/fact (zelph/var "A") "is located in" B))
<zelph/node A "is located in" B>
zelph> %(def c2 (zelph/fact B "is member of" (zelph/var "K")))
<zelph/node B "is member of" K>
zelph> %(def joined (let [s (zelph/set c1 c2)] (zelph/fact s "~" "conjunction") s))
<zelph/node {(A "is located in" B) (B "is member of" K)}>
zelph> %(length (zelph/query joined))
1

Every call to zelph/var makes a new variable, whatever name it is given β€” otherwise a program building many patterns in one loop would join them all by accident. The name is display only; it is what lets the binding be read back as (get r 'B).

Lists: zelph/list and zelph/list-chars

zelph has two list syntaxes, each with a Janet counterpart:

Node lists (< a b c > in zelph) create an ordered list of existing nodes:

%(zelph/list "Berlin" "Paris" "London")

Equivalent to:

< Berlin Paris London >

Compact lists (<abc> in zelph) split a string into individual characters, resolve each to a named node, and build a cons-list from them:

%(zelph/list-chars "42")

Equivalent to:

<42>

This is the foundation of zelph's Semantic Math system, where numbers are topological structures within the graph.

Sets and collections: zelph/set, zelph/collection

Both create an unordered grouping and return its super-node. They differ in identity, exactly as the two literals do (see Set Constants and Collections): zelph/set hashes its members, so the same elements always yield the same node and membership cannot be extended; zelph/collection returns a fresh container that (member in container) adds to.

%(zelph/set "red" "green" "blue")

Equivalent to:

{ red green blue }
%(zelph/collection "red" "green" "blue")

Equivalent to:

@{ red green blue }

A rule's conjunction of conditions is a collection, not a set constant: its members are condition patterns, and the rule needs a container of its own.

Janet API Reference (zelph/*)

The embedded Janet environment exposes the following functions. Unless stated otherwise, functions accept either strings (resolved as node names in the current .lang) or zelph/node values.

Graph construction (mutating)

  • (zelph/resolve name)
    Resolve (and create if needed) the node named name in the current language.

  • (zelph/var &opt name)
    Create a fresh variable node and return it. Every call yields a new variable, whatever it is named β€” the name is display only, and is what makes the binding readable as (get r 'name); an unnamed variable still matches but contributes no column. Use it when the conditions of one pattern are built in separate blocks, where a variable symbol would mean a different variable each time: see The Scope of a Variable Symbol.

  • (zelph/fact s p o & more-objects)
    Create a fact node for s p o... and return the statement node.

  • (zelph/set nodes...)
    Create a SET CONSTANT from the given elements and return its super-node. Identified by its members, so the same elements always yield the same node and membership cannot be extended. An element that is a variable makes the members unknown, and the call then behaves as zelph/collection.

  • (zelph/collection nodes...)
    Create a COLLECTION from the given elements and return its super-node. A container with its own identity: two calls with the same elements yield two different nodes, and (member in container) adds to it. This is what a rule's conjunction of conditions is.

  • (zelph/list nodes...)
    Create a cons list from existing nodes; the first argument becomes the outermost cons cell.

  • (zelph/list-chars str)
    Create a cons list from the characters of str. Characters are reversed before cons construction, matching the <...> compact list syntax.

  • (zelph/negate pattern)
    Mark a fact pattern as a negation condition and return the pattern node (equivalent to *(pattern) ~ negation in zelph syntax). In zelph syntax, this is also what Β¬(pattern) desugars to.

  • (zelph/rule conditions & consequences)
    Convenience constructor for rules.
    conditions must be a non-empty array/tuple of fact (pattern) nodes; consequences are one or more fact nodes.
    Returns the conjunction set node.
    Unlike a parsed ... => ... statement, this does not check whether the graph already holds the same rule up to a renaming of its variables (see Rules Say Themselves Only Once): the condition nodes are created by the caller, before zelph/rule sees them, so there is nothing zelph could roll back without touching facts the caller asked for. A program that builds the same rule repeatedly should either build it once or go through zelph/import.

Querying (read-only)

  • (zelph/query pattern-node)
    Execute a query and return an array of tables, mapping variable symbols (e.g. 'X) to bound zelph/node values.
    The argument is typically the return value of (zelph/fact 'X ... 'Y).

  • (zelph/exists s p o & more-objects)
    Check whether the fact was claimed β€” asserted or derived β€” without creating nodes/facts. Returns boolean. A statement that occurs only as a rule's own condition or consequence is not claimed; see Claimed or merely written down. A fact carrying further objects satisfies the question: (zelph/exists "a" "p" "b") is true when the graph says a p b c, which is what a rule with that condition matches.

  • (zelph/mentioned s p o & more-objects)
    Check whether the fact node is present at all, whether or not anybody claimed it. True for a rule's own patterns, which zelph/exists reports as absent. Use it to inspect rule structure; use zelph/exists to ask about the data.

  • (zelph/name node &opt lang)
    Return the node’s name as a string (or nil if unnamed). Optional lang selects the naming language.

  • (zelph/sources predicate target)
    Return exactly those nodes S for which the claimed fact S predicate target exists β€” i.e. nodes in the subject role. target must be in the object role of the matching fact: given a R b and b R c, (zelph/sources R b) returns only a (not c), and (zelph/targets b R) returns only c (not a). A statement ABOUT such a fact is not a subject of it, and a rule's own pattern is not an answer.

  • (zelph/targets subject predicate)
    Return all objects O such that subject predicate O exists (read-only traversal).

  • (zelph/closure start predicate &opt include-start)
    Transitive closure following predicate forward (subject to object). include-start true gives the reflexive closure (the * of SPARQL property paths).

  • (zelph/closure-sources target predicate &opt include-target)
    Transitive closure following predicate backward (object to subject). include-target true gives the reflexive closure.

Cons cell inspection (read-only)

  • (zelph/car cell)
    Return the car (first element) of a cons cell, or nil if cell is not a cons cell.

  • (zelph/cdr cell)
    Return the cdr (rest of list) of a cons cell. Returns the nil list terminator node for the last cell; returns nil if cell is not a cons cell.

Script import

  • (zelph/import path & args)
    Load and execute a script as a module, through the same machinery as the .import command β€” not as a session, whatever the surrounding script is: path is resolved against the current working directory first, then the zelph standard library, and the .zph extension is optional. Any further arguments must be strings; they are passed to the imported script and available there via (dyn :args). Returns nil.
    This is the way to pull .zph files into the network from Janet code β€” for example (zelph/import "decimal-arithmetic") to load the decimal arithmetic rules before working with &-literals.
    Two restrictions apply: .janet files are rejected (use zelph/run-script below, or Janet's own import, use, or dofile, for Janet modules), and the function must be called from the main thread, not from inside ev/spawn-thread.

  • (zelph/run-script path & args)
    Run a Janet source file the way the janet CLI would, and the counterpart of zelph/import for .janet files: the file is evaluated in a fresh environment, and its main function β€” if it defines one β€” is then called with the script path followed by args. Returns nil.
    This is the same runner the binary uses for zelph <file.janet>, so a script written for that invocation runs unchanged when called from Janet. What differs is the surroundings, not the runner: on the command line the file is a session, so what it asserts anchors the deduction filter and auto-run fires as usual, while a call to zelph/run-script inherits whatever surrounds it.
    Two properties are worth knowing, because neither follows from the name:

    • Relative imports such as (use ./helper) resolve against the script's directory, not the process's working directory, so a script can be started from anywhere.
    • Every run starts from an empty module cache. Janet's require caches modules process-wide, so without this a second run of the same script would keep the first version of every dependency it pulled in β€” an edit to helper.janet would be invisible for the rest of the session.

Running the engine

  • (zelph/run)
    Run forward chaining to a fixed point, exactly like the .run command. Returns nil. Main thread only.

  • (zelph/run-once)
    Run a single inference pass, exactly like the .run-once command: derives what one application of the rules yields instead of iterating to a fixed point. Returns nil. Main thread only.

  • (zelph/run-delta)
    Run inference seeded by the facts created since the previous run, exactly like the .run-delta command. Returns nil. Main thread only. See Reasoning incrementally.

Auto-run is tied to processing an input line: it fires after a statement, and after a Janet block closes. Code that only calls the Janet API β€” a program driving zelph as a library, or a long-running script that keeps asserting facts between queries β€” never triggers it, and so has to run the engine itself.

Given this script:

.auto-run
%(zelph/fact "socrates" "~" "human")
%(zelph/rule [(zelph/fact 'X "~" "human")] (zelph/fact 'X "~" "mortal"))
%(zelph/out (string "derived before run: " (zelph/exists "socrates" "~" "mortal")))
%(zelph/run)
%(zelph/out (string "derived after run: " (zelph/exists "socrates" "~" "mortal")))

the session reads:

zelph> Auto-run is now disabled.
zelph-> <zelph/node socrates ~ human>
zelph-> <zelph/node {(X ~ human)}>
zelph-> derived before run: false
zelph-> Starting reasoning with 24 worker threads.
(socrates ~ mortal) ⇐ {(socrates ~ human)}
Reasoning complete. Total unification matches processed: 1. Total contradictions found: 0.
Reasoning summary: 1 matches processed, 0 contradictions found.
Parallel unifications activated for 0 distinct fixed relations.
Reasoning complete in 0h0m0.000s – 1 matches processed, 0 contradictions found.
Ready.
zelph-> derived after run: true
zelph->

The rule is in the graph from the moment it is created, but its consequence only exists once the engine has run.

Reasoning incrementally

zelph/run always begins with one classic pass over the whole graph, because it cannot know what the graph looked like before. That pass costs time proportional to the graph β€” so a program that alternates between asserting a little and reasoning pays, every time, for everything it has ever asserted. This is the shape of most library use: a fact base per document, per position, per request.

zelph/run-delta removes that term. It seeds the fixpoint with the facts created since the previous run and lets semi-naive evaluation continue from there, so the cost follows the size of the addition instead of the size of the graph.

%(zelph/fact "plato" "~" "human")
%(zelph/run-delta)
%(zelph/out (string "plato is mortal: " (zelph/exists "plato" "~" "mortal")))
zelph-> <zelph/node plato ~ human>
zelph-> Starting reasoning with 24 worker threads.
(plato ~ mortal) ⇐ {(plato ~ human)}
Reasoning complete. Total unification matches processed: 0. Total contradictions found: 0.
Reasoning summary: 0 matches processed, 0 contradictions found.
Parallel unifications activated for 0 distinct fixed relations.
Reasoning complete in 0h0m0.000s – 0 matches processed, 0 contradictions found.
Ready.
zelph-> plato is mortal: true

Measured on a graph holding fact bases of 72 facts each, with one rule, asserting one further fact base and running again:

fact bases in the graph zelph/run zelph/run-delta
100 48.7 ms 0.98 ms
400 190.7 ms 1.26 ms
1200 532.3 ms 1.02 ms

Both derive the same facts. The full run grows with the graph; the seeded one does not.

This is only equivalent to a full run when the graph already is a fixpoint of the current rules β€” otherwise the skipped pass is exactly the one that would have found the older consequences. zelph/run-delta therefore checks, and falls back to a full pass (with a note on the diagnostic channel) unless all of the following hold:

  • a previous run has happened,
  • no rule was created since it β€” a new rule has to see facts older than itself,
  • fewer than a million facts have accumulated since, so the record is still kept (past that it is dropped rather than grown without bound, and anything of that size is a bulk load for which a classic pass is the right answer),
  • .semi-naive is on, since delta seeding is the semi-naive machinery.

Note that facts created by an imported script count as ordinary additions here. The record is not tied to interactive input, precisely so that a program driving zelph as a library β€” which never enters a statement β€” can use it.

The safe pattern is therefore: define the rules, zelph/run once, then assert and zelph/run-delta per unit of new data.

Scoped work: clusters

The graph is monotonic. A program that asserts a fact base, reasons about it and reads the conclusions has no way to take the fact base out again, so every question it ever asks stays β€” and the pattern "one fact base per document, per position, per request" accumulates without bound.

A cluster is the answer. It records the IDs of the nodes created while it is active, and dropping it removes exactly those. Nodes that already existed are never recorded, so a drop cannot reach them: a cluster is safe as scratch space over a graph loaded from disk. This is the same mechanism .explain uses internally to evaluate a pattern without asserting it.

  • (zelph/cluster &opt name)
    Activate the named cluster, creating it if needed. nil or "default" deactivates cluster tracking. Without an argument nothing changes. In every case the function returns the name of the cluster that is active afterwards, or nil for the default. Main thread only.

  • (zelph/cluster-drop name)
    Remove every node recorded in the cluster β€” with its edges and names β€” and return how many were removed. Facts outside the cluster that referenced cluster nodes lose those connections. The default cluster cannot be dropped; an unknown name removes nothing and returns 0. Main thread only.

  • (zelph/clusters)
    An array of [name node-count] tuples, one per existing cluster. Main thread only.

The scratch-space pattern, which is what makes assert–reason–read–discard loops possible at all:

(zelph/cluster "scratch")
(zelph/fact "subject-of-this-request" "~" "something")
(zelph/run-delta)
(def answer (zelph/query ...))
(zelph/cluster nil)
(zelph/cluster-drop "scratch")

Unlike .cluster and .cluster-drop, these print nothing. That is deliberate: a caller that scopes one question per iteration of its own loop invokes them thousands of times, and the commands' status lines would be noise on the caller's own output channel rather than information. The return values carry what a program actually wants to know.

Clusters are session state and are not persisted by zelph/save.

Persistence

  • (zelph/save file)
    Save the current network to a binary file, exactly like the .save command. The filename must end with .bin. Returns nil. Main thread only.

  • (zelph/load file)
    Load a previously saved network state, exactly like the .load command:

  • If file ends with .bin, the serialized network is loaded directly (fast).
  • If file ends with .json or .json.bz2 (Wikidata dump), the data is imported and a .bin cache file is created in the same directory for faster future loads.
    In the interactive REPL, loading disables auto-run (large datasets). Inside a module (.import) auto-run is already suspended for the duration of the load and restored afterwards. A script named on the command line is a session, so there .load disables auto-run exactly as it does in the REPL β€” see Scripts and Modules. Returns nil. Main thread only.

Neural network functions

zelph 0.9.7 adds a neural substrate: weighted edges act as synapses, layers are ordinary sets, and sub-graphs compile into feed-forward networks that rules can consult via the β‰ˆ operator. The full documentation β€” including semantics, training workflow, and a Wikidata proof of concept β€” is on the dedicated page Neural Networks in the Graph. For completeness, the functions:

  • (zelph/nn-connect from to &opt weight) β€” create a raw weighted edge (synapse); invisible to reasoning. Default weight 1.
  • (zelph/weight from to) β€” weight of a raw edge, or nil if the edge does not exist.
  • (zelph/set-weight from to w) β€” set the weight of an existing raw edge.
  • (zelph/nn-compile layers) β€” compile a feed-forward view of a sub-graph (layer nodes, input first); returns an integer handle.
  • (zelph/nn-nodes handle layer) β€” neurons of a compiled layer in index order.
  • (zelph/nn-eval handle inputs) β€” forward pass with plain number vectors.
  • (zelph/nn-train handle inputs targets &opt learning-rate) β€” one SGD step; returns the loss before the update.
  • (zelph/nn-write-back handle) β€” write trained weights back into the graph's weight store (required for .save and for β‰ˆ conditions).
  • (zelph/nn-snapshot handle) β€” copy the weights out as an array of arrays of numbers.
  • (zelph/nn-restore handle snapshot) β€” put a snapshot back; shapes must match, absent synapses stay absent.
  • (zelph/nn-connect-layers from-layer to-layer &opt scale seed) β€” densely wire two layers; idempotent, preserves existing weights.
  • (zelph/nn-train-nodes handle inputs targets &opt learning-rate) β€” SGD step addressing neurons by graph node (multi-hot).
  • (zelph/nn-eval-nodes handle inputs &opt top-k) β€” node-addressed forward pass; sorted [node score] tuples.
  • (zelph/approx pattern net-name) β€” tag a fact pattern as a neural rule condition; desugared form of β‰ˆnet(pattern). Returns the tag fact.

Threading. Unlike most of the API above, a compiled network may be used from more than one thread. Any number of threads may evaluate concurrently β€” nn-eval, nn-eval-nodes, nn-snapshot, nn-write-back β€” while a training step (nn-train, nn-train-nodes) or nn-restore excludes them for its duration. So a program may evaluate a network from a worker thread while another thread trains it, which is what lets training run continuously alongside the work that uses the result. nn-compile is not synchronised: build the network before sharing its handle. The guarantee covers the network's weights only β€” the graph operations marked "Main thread only" above stay main-thread-only, so zelph/save and zelph/load must not run while another thread trains.

The two node-addressed calls price the input layer by the number of active neurons rather than by its width, because they are told which ones are non-zero; the dense pair cannot be. Same numbers, so prefer them whenever the input is a multi-hot encoding of a large domain β€” see Neural networks.

Output

  • (zelph/out text)
    Emit text through zelph's own output pipeline (the Out channel) rather than Janet's stdout. Use this in modules: it is the only form that reaches the REPL, .log capture, the WebAssembly playground and the test harness alike, and it respects the quieting that ? applies to its inference pass. Janet's print writes to the process's standard output and is invisible to all of them. The standard library uses it for load banners, e.g. (zelph/out "math-syntax loaded: $( ... ) term islands").

Display registration

A script can declare how its own notation is written, so that node_to_string renders matching terms in it. The C++ side knows precedence, associativity, delimiters, numeral prefix and leaf grammar β€” never a concrete operator. A term is rendered under a scheme only when the whole subtree is expressible in it; otherwise the default rendering is used.

  • (zelph/register-display-scheme name open close &opt options)
    Declare a scheme. open/close are the delimiters wrapped around a rendered term ("$( " and " )" for the stdlib's term islands). options is a struct:

    • :numeral-prefix β€” the sigil numerals carry inside the scheme ("&" keeps the default, "" drops it).
    • :name-first, :name-chars β€” the identifier grammar. A leaf is writable in the scheme only if its name matches; a scheme that declares no grammar can never deviate from the default rendering, which is the safe default rather than a limitation.
  • (zelph/set-infix-display scheme entries)
    Register infix operators into a declared scheme. entries is an array of [predicate precedence associativity], with associativity :left or :right. Higher precedence binds tighter. Registration is additive across calls, but a predicate belongs to at most one scheme β€” re-registering it is an error.

  • (zelph/set-application-display scheme predicates)
    Register predicates whose facts render in call notation: (S P O) becomes S(O). Application heads must be atomic names; a composite head falls back to the default rendering. Registering a predicate here also excludes it from the self-fact display sugar.

Display alone is half a notation

Registering an operator for display does not teach any parser to read it back, so zelph can end up printing syntax it cannot itself consume. For the stdlib's term islands there is a combined entry point, math-syntax/operator, which extends the island grammar and the display scheme from one table β€” see The math Front End. Reach for zelph/set-infix-display directly only when you are registering a notation you do not intend to parse.

Redefinable hooks

  • (zelph/number str)
    Called by the parser for every &-prefixed number literal (e.g. &42 becomes (zelph/number "42")). The default implementation raises an error; arithmetic scripts such as stdlib/decimal-arithmetic.zph (decimal) or stdlib/binary-arithmetic.zph (binary) redefine it to build the cons-list representation of their choice. See Number Literals.

  • (zelph/set-number-digits digits)
    Register the digit alphabet of the loaded number representation, as an array of digit nodes or names in ascending order of value (e.g. ["0" "1"] for binary). node_to_string then displays every nil-terminated cons list consisting solely of these digit nodes as a decimal &-literal -- the inverse of zelph/number. All other cons lists keep the generic <...> display. An empty array disables the feature.

  • (zelph/no-selffact-sugar preds...)
    Exclude predicates from the self-fact display sugar. A fact whose subject and object are the same node normally prints in the compact prefix form :pred subject (see The Self-Fact Prefix :). For term-forming operators this contraction is undesirable: (&1 + &1) is a self-fact only because all terms are hash-consed, and should print verbose. Registered predicates always render as S P S. The registration is additive across calls, so stacked modules can each register their own operators; it affects display only β€” :+ &1 remains valid input β€” and is session state like the digit alphabet (cleared by .reset, not persisted). The arithmetic modules register their operators (+, -, d+, ...), stdlib/eml.zph registers eml.

Referencing Janet Variables in zelph: Unquote ,

The , (comma) operator bridges the two languages in the opposite direction: it allows zelph statements to reference values defined in Janet. Prefix any Janet variable name with , inside zelph syntax:

%(def my-city (zelph/resolve "Berlin"))
%(def my-relation "is capital of")

,my-city ,my-relation Germany

This is equivalent to writing Berlin "is capital of" Germany, but the subject and predicate come from Janet variables.

Important: unquoting is written as ,name without whitespace.
A comma that is followed by whitespace (or )) is interpreted as a conjunction separator inside (cond1, cond2, ...).

  • ,pred β†’ unquote the Janet variable pred
  • , pred β†’ not unquote; inside conjunction parentheses it acts as a separator, and outside it is simply invalid syntax

It takes a name, not an expression: ,(zelph/resolve "Berlin") does not parse. Bind the value first with %(def berlin ...) and write ,berlin.

How Unquote Works

The unquoted variable name is emitted directly into the generated Janet code. At runtime, zelph's argument resolver handles the value based on its Janet type:

  • zelph/node β€” used directly as a graph node (language-independent, unambiguous).
  • String β€” resolved as a node name in the current language (identical to writing the name in zelph syntax).

This means you can use either form depending on your needs:

%(def node-a (zelph/resolve "Berlin"))   # zelph/node: precise, language-independent
%(def node-b "Berlin")                    # string: resolved at use time

,node-a ~ city    # Uses the node directly
,node-b ~ city    # Resolves "Berlin" in current .lang

For Wikidata work where IDs are language-independent, both forms are equivalent. For multilingual scenarios, zelph/resolve gives you explicit control over when the name is resolved.

Unquote in Complex Structures

The , operator works anywhere a value is expected β€” in facts, sets, lists, and nested expressions:

%(def pred "P31")
%(def obj (zelph/resolve "Q5"))

# Query: find all instances of Q5 (human)
X ,pred ,obj

# In a set
{ ,node-a ,node-b ,node-c }

# In a nested expression
(,subject ,pred ,obj)

Practical Patterns

Generating Facts from Data

A common pattern is defining data in Janet and generating zelph facts programmatically:

%
(def taxonomy
  [["Brontosaurus" "Apatosaurinae"]
   ["Apatosaurus" "Apatosaurinae"]
   ["Diplodocus" "Diplodocinae"]
   ["Apatosaurinae" "Diplodocidae"]
   ["Diplodocinae" "Diplodocidae"]])

(each [child parent] taxonomy
  (zelph/fact child "parent taxon" parent))
%

# Now use zelph's inference:
(X "parent taxon" Y, Y "parent taxon" Z) => (X "parent taxon" Z)

After inference, zelph deduces that Brontosaurus and Apatosaurus have Diplodocidae as an ancestor β€” entirely from data generated by a Janet loop.

Parameterized Rules

Janet functions can encapsulate common rule patterns:

%
(defn transitive-rule [rel]
  (let [condition
        (zelph/collection
          (zelph/fact 'X rel 'Y)
          (zelph/fact 'Y rel 'Z))]
    (zelph/fact condition "~" "conjunction")
    (zelph/fact condition "=>" (zelph/fact 'X rel 'Z))))

(transitive-rule "is part of")
(transitive-rule "is ancestor of")
(transitive-rule "is located in")
%

A single function generates a transitive inference rule for any relation. The let pattern captures the condition set and reuses it β€” the Janet equivalent of the focus operator * in zelph syntax.

Parameterized Queries

Similarly, queries can be wrapped in reusable functions:

%
(defn find-all [relation target]
  (zelph/query (zelph/fact 'X relation target)))

(find-all "is located in" "Europe")
(find-all "parent taxon" "Diplodocidae")
%

Each zelph/query call resets the variable scope, so the two calls produce independent results.

Wikidata Query Helpers

Janet functions can provide a higher-level query interface for Wikidata:

%
(defn wikidata-query [& clauses]
  "Generate and execute a conjunction query from S-P-O triples."
  (let [facts (map (fn [[s p o]] (zelph/fact s p o)) clauses)
        condition-set (zelph/collection ;facts)]
    (zelph/fact condition-set "~" "conjunction")
    (zelph/query condition-set)))

# Find fossil taxa at genus rank
(wikidata-query ["X" "P31" "Q23038290"]
                ["X" "P105" "Q34740"])
%

This generates and executes the equivalent of:

(*{(X P31 Q23038290) (X P105 Q34740)} ~ conjunction)

Read-Only Graph Inspection

The following functions inspect the graph without modifying it. Unlike zelph/fact, they never create nodes or facts as a side effect. If a referenced name does not exist in the graph, the functions return false, nil, or an empty array as appropriate.

Existence Check: zelph/exists

zelph/exists checks whether a fact was claimed β€” asserted or derived:

%(zelph/exists "Berlin" "is located in" "Germany")   # β†’ true
%(zelph/exists "Berlin" "is located in" "France")     # β†’ false
%(zelph/exists "Tokyo" "is located in" "Japan")       # β†’ false (if Tokyo was never added)

This is the read-only counterpart to zelph/fact. While zelph/fact creates facts as a side effect (and is therefore unsuitable for conditional checks), zelph/exists purely queries the graph.

Like zelph/fact, it accepts strings (resolved in the current language), zelph/node values, or a mix:

%(def berlin (zelph/resolve "Berlin"))
%(zelph/exists berlin "~" "city")

Claimed or Merely Written Down

Writing a rule materialises its conditions and consequences as real fact nodes β€” the engine has nothing else to match against. Such a node is a statement the rule writes down, not one anybody claimed, and the whole read surface says so: zelph/exists, zelph/sources, zelph/targets and the two closures all answer about claimed facts, which is what the queries and the reasoning engine have always meant by a fact.

zelph/mentioned asks the other question β€” is this node there at all β€” and is the way to inspect rule structure:

zelph> (Berlin is-capital-of Germany) => (Germany has-capital yes)
zelph> Paris is-capital-of France
zelph> %(zelph/out (string "exists Berlin: " (zelph/exists "Berlin" "is-capital-of" "Germany")))
exists Berlin: false
zelph> %(zelph/out (string "mentioned Berlin: " (zelph/mentioned "Berlin" "is-capital-of" "Germany")))
mentioned Berlin: true
zelph> %(zelph/out (string "exists Paris: " (zelph/exists "Paris" "is-capital-of" "France")))
exists Paris: true
zelph> %(zelph/out (string "sources: " (string/join (sorted (map zelph/name (zelph/sources "is-capital-of" "Germany"))) ",")))
sources: 

Claiming the statement β€” by typing it, by deriving it, or by calling zelph/fact β€” makes it data from that moment on, and the rule fires:

zelph> %(zelph/fact "Berlin" "is-capital-of" "Germany")
<zelph/node Berlin is-capital-of Germany>
(Germany has-capital yes) ⇐ (Berlin is-capital-of Germany)
zelph> %(zelph/out (string "exists Berlin now: " (zelph/exists "Berlin" "is-capital-of" "Germany")))
exists Berlin now: true

A pattern carrying a variable is never data either, so a rule condition such as (X p Y) is invisible to all of the above by the same rule.

Node Names: zelph/name

zelph/name returns the name of a node as a string, or nil if the node has no name:

%(def results (zelph/query (zelph/fact 'X "is located in" 'Y)))
%(each r results
   (printf "%s is located in %s"
     (zelph/name (get r 'X))
     (zelph/name (get r 'Y))))

An optional second argument specifies the language:

%(zelph/name some-node)          # current language (as set by .lang)
%(zelph/name some-node "en")     # English
%(zelph/name some-node "wikidata") # Wikidata ID

If no name exists in the requested language, zelph/name falls back through English, zelph, and other available languages before returning nil.

Graph Traversal: zelph/sources and zelph/targets

These functions traverse the graph along a specific relation, returning arrays of zelph/node values:

  • zelph/sources finds all subjects connected to a target via a predicate.
  • zelph/targets finds all objects connected from a subject via a predicate.
# Given: Berlin "is located in" Germany, Potsdam "is located in" Germany
%(zelph/sources "is located in" "Germany")   # β†’ @[<Berlin> <Potsdam>]
%(zelph/targets "Berlin" "is located in")    # β†’ @[<Germany>]

Common patterns with sets and lists:

# Elements of a set (elements are linked via "in")
%(zelph/sources "in" my-set)        # β†’ all elements of the set

# Decompose a cons cell (Lisp-style list node)
%(zelph/car cons-cell)              # β†’ the first element (car)
%(zelph/cdr cons-cell)              # β†’ the rest of the list (cdr)

# Instances of a concept
%(zelph/sources "~" "city")         # β†’ all nodes that are instances of "city"

# What concept an instance represents
%(zelph/targets inst-node "~")      # β†’ @[<concept-node>]

# Which set a node belongs to
%(zelph/targets elem-node "in")     # β†’ @[<set-node>]

List Decomposition: zelph/car and zelph/cdr

These functions decompose cons cells (Lisp-style list nodes), mirroring the classic Lisp car/cdr operations:

  • zelph/car returns the first element (subject) of a cons cell.
  • zelph/cdr returns the rest of the list (object) of a cons cell.

Both return nil for invalid input. zelph/cdr returns the nil node for the last cell in a list.

%(def list-42 (zelph/list-chars "42"))
%(zelph/car list-42)                    # β†’ <zelph/node> for "4"
%(zelph/cdr list-42)                    # β†’ <zelph/node> for the sublist <2>
%(zelph/car (zelph/cdr list-42))        # β†’ <zelph/node> for "2"
%(zelph/cdr (zelph/cdr list-42))        # β†’ <zelph/node> for nil

Important: zelph/sources and zelph/targets do not work for decomposing cons cells, because cons cells are relation nodes (fact nodes) in the graph, not entities that appear as subjects or objects in higher-level facts. Use zelph/car and zelph/cdr instead.

Practical Example: Inspecting a List

Combining zelph/car and zelph/cdr to walk a cons-list:

zelph> <42>
<2 4>
%
(def list-42 (zelph/list-chars "42"))
(def nil-node (zelph/resolve "nil"))

# Walk the cons-list using car/cdr
(var current list-42)
(while (and current (not= current nil-node))
  (let [element (zelph/car current)]
    (when element
      (prin (zelph/name element))))
  (set current (zelph/cdr current)))
(print) # newline
%
# Output: 42

Note: zelph/car and zelph/cdr mirror the classic Lisp operations. zelph/sources and zelph/targets cannot be used for cons cell decomposition because cons cells are relation nodes in the graph, not entities.

Combining with Query Results

Read-only functions are especially useful for processing query results:

%
(def results (zelph/query (zelph/fact 'X "is located in" 'Y)))

# Filter using zelph/exists (no side effects!)
(def germany (zelph/resolve "Germany"))
(def in-germany
  (filter (fn [r] (= (get r 'Y) germany)) results))

# Alternatively, check a different relation for each result
(def cities-that-are-capitals
  (filter (fn [r] (zelph/exists (get r 'X) "~" "capital"))
    results))

# Display with names
(each r in-germany
  (printf "%s" (zelph/name (get r 'X))))
%

Building a SPARQL-like Interface

Combining Janet's macro system with zelph's API, you can create domain-specific query languages. Here is a sketch of a SELECT ... WHERE syntax:

%
(defmacro sparql-select [vars & where-clauses]
  ~(wikidata-query ,;(map (fn [clause] clause) where-clauses)))

# Usage:
# "Select ?x where { ?x P31 Q5 . ?x P27 Q183 }"
# becomes:
(sparql-select [X]
  ["X" "P31" "Q5"]
  ["X" "P27" "Q183"])
%

The macro translates a SPARQL-inspired syntax into zelph conjunction queries. Since Janet is a full programming language, this can be extended with OPTIONAL (using negation), FILTER, and other SPARQL features β€” each mapped to the appropriate zelph construct.

Rule Construction: zelph/rule and zelph/negate

These functions simplify the creation of inference rules from Janet. While rules can always be built manually using zelph/collection, zelph/fact, and let bindings (see Rules in Janet: The let Pattern), zelph/rule encapsulates the entire pattern in a single call.

zelph/negate

Marks a fact pattern as a negation condition. Returns the pattern node itself (equivalent to the focus operator * in (*(pattern) ~ negation)):

%(zelph/negate (zelph/fact 'A ".." 'X))

This is equivalent to the zelph syntax fragment (*(A .. X) ~ negation) inside a condition set.

zelph/rule

Creates a complete inference rule: a conjunction of conditions linked to one or more consequences via =>.

(zelph/rule conditions consequence1 consequence2 ...)
  • conditions: An array or tuple of fact nodes (the conjunction).
  • consequences: One or more fact nodes to deduce when conditions match.
  • Returns: The condition set node (the rule's identity in the graph).

Example β€” Transitivity rule:

# zelph syntax:
(*{(X R Y) (Y R Z) (R ~ transitive)} ~ conjunction) => (X R Z)

# Janet equivalent using zelph/rule:
%(zelph/rule
   [(zelph/fact 'X 'R 'Y)
    (zelph/fact 'Y 'R 'Z)
    (zelph/fact 'R "~" "transitive")]
   (zelph/fact 'X 'R 'Z))

Example β€” Negation (finding the last element of a list):

# zelph syntax:
(*{(A in _Num) (*(A .. X) ~ negation)} ~ conjunction) => (A "is last digit of" _Num)

# Janet equivalent:
%(zelph/rule
   [(zelph/fact 'A "in" '_Num)
    (zelph/negate (zelph/fact 'A ".." 'X))]
   (zelph/fact 'A "is last digit of" '_Num))

Example β€” Multiple consequences:

%(zelph/rule
   [(zelph/fact 'A "~" "human")]
   (zelph/fact 'A "has" "consciousness")
   (zelph/fact 'A "has" "mortality"))

Parameterized Rules with zelph/rule

Combined with Janet functions, zelph/rule enables concise parameterized rule generation:

%
(defn transitive-rule [rel]
  (zelph/rule
    [(zelph/fact 'X rel 'Y)
     (zelph/fact 'Y rel 'Z)]
    (zelph/fact 'X rel 'Z)))

(transitive-rule "is part of")
(transitive-rule "is ancestor of")
(transitive-rule "is located in")
%

Compare this to the manual let pattern from Parameterized Rules β€” zelph/rule eliminates the boilerplate of creating the set, tagging it as a conjunction, and linking the consequence.

Setting Up Digit-Wise Addition

As a concrete example of combining zelph/rule, zelph/negate, and Janet loops to set up a domain for the reasoning engine, here is the setup for single-digit arithmetic. All reasoning happens purely within zelph's inference engine β€” Janet only generates the initial facts and rules:

%
# Digit successor relationships
(for i 0 9
  (zelph/fact (zelph/list-chars (string i)) ".." (zelph/list-chars (string (+ i 1)))))

# Mark all single digits
(for i 0 10
  (zelph/fact (zelph/list-chars (string i)) "~" "digit"))

# Single-digit addition lookup table (100 entries)
(for a 0 10
  (for b 0 10
    (let [sum (+ a b)
          d (% sum 10)
          c (math/floor (/ sum 10))
          addition-fact (zelph/fact (zelph/list-chars (string a)) "+" (zelph/list-chars (string b)))]
      (zelph/fact addition-fact "digit-sum" (zelph/list-chars (string d)))
      (zelph/fact addition-fact "digit-carry" (zelph/list-chars (string c))))))

# Rule: find the last element of any cons-list
# Base case: the car of a cell ending in nil
(let [base-cell (zelph/fact 'A "cons" "nil")]
  (zelph/rule [base-cell]
    (zelph/fact 'A "is last of" base-cell)))

# Recursive case: propagate through the cons chain
(zelph/rule
  [(zelph/fact 'B "is last of" '_Rest)
   (zelph/fact 'A "cons" '_Rest)]
  (zelph/fact 'B "is last of" (zelph/fact 'A "cons" '_Rest)))

# The first element of a cons-list is trivially the car of the outermost cell,
# accessible via (zelph/car list-node) β€” no inference rule needed.
%

The rules above are general-purpose (they work on any list, not just numbers). The lookup table encodes digit arithmetic as graph facts. From here, additional rules can process multi-digit numbers by walking lists from right to left, extracting digits, applying the lookup table, handling carries, and constructing new result lists using fresh variables β€” all within zelph's reasoning engine.

Extending the REPL: zelph/register-keyword

(zelph/register-keyword keyword handler) registers a custom multi-line syntax block for the REPL and for .zph scripts. After the keyword is entered (optionally with content on the same line), subsequent lines are accumulated verbatim until an empty line, then passed as a single string to handler. The handler may return :incomplete to signal that the block is not yet complete (e.g. unbalanced braces) β€” accumulation then continues, and a second consecutive blank line forces dispatch. String results are printed line by line.

This is the mechanism behind zelph's SPARQL support: the sparql keyword is an ordinary registered handler, not a built-in.

zelph/register-keyword has a second, three-argument form for inline keywords ("expression islands"): (zelph/register-keyword open close handler). Whenever open appears inside a zelph statement (outside quoted atoms and comments), the raw text up to close is passed to handler, which must return a zelph/node; the node replaces the island in the statement and can therefore stand in any value position β€” subjects, objects, nested facts, rule conditions and consequences. The handler may return :incomplete to extend the island to the next occurrence of close, so delimiters nested inside the island's own grammar work naturally; handlers must not create graph structure before accepting their input. Variables created inside an island share the surrounding statement's scope. This is the host mechanism behind the stdlib's term islands ($( ... )), whose grammar is itself an ordinary Janet PEG in a .zph module β€” the language grows in scripts, not in C++.

Summary: zelph Syntax and Janet Equivalents

zelph Syntax Janet Equivalent Description
Berlin (zelph/resolve "Berlin" "en") Resolve a name to a node, with an optional language argument
X, _Var 'X, '_Var Variable (single uppercase letter or _-prefixed)
sun is yellow (zelph/fact "sun" "is" "yellow") Create a fact (triple)
(sun is yellow) (zelph/fact "sun" "is" "yellow") Nested fact (returns relation node)
{ red green blue } (zelph/set "red" "green" "blue") Set constant β€” identified by its members, cannot be extended
@{ red green blue } (zelph/collection "red" "green" "blue") Collection β€” own identity, membership can grow
< Berlin Paris > (zelph/list "Berlin" "Paris") Ordered cons-list (first element is the head/outermost cons cell)
<abc> (zelph/list-chars "abc") Compact char cons-list (LSB-first: rightmost char = outermost)
*expr let binding to capture and reuse a sub-expression Focus operator
,var in zelph Direct variable reference in generated code Unquote a Janet value (no whitespace after comma)
% code β€” Execute Janet inline (line start only; never a term)
% (bare) β€” Toggle Janet block mode
X ~ human (zelph/query (zelph/fact 'X "~" "human")) Query β€” returns array of @{symbol node} tables
(no equivalent) (zelph/exists "sun" "is" "yellow") Check if a fact exists (read-only)
(no equivalent) (zelph/name node) Get the name of a node as a string
A ~ city (zelph/sources "~" "city") Find all subjects for a predicate–object pair
Berlin "is located in" L (zelph/targets "Berlin" "is located in") Find all objects for a subject–predicate pair
Berlin R city (no equivalent) Find all predicates that connect a given Berlin and city
S P O (no equivalent) List all facts in the network (use with caution on large databases)
(*(P) ~ negation) (zelph/negate (zelph/fact ...)) Mark a pattern as negation condition (evaluates to the pattern node)
Β¬(P) (zelph/negate P) Negation sugar for patterns (evaluates to the pattern node)
(*{...} ~ conjunction) => ... (zelph/rule [conditions] consequences...) Create inference rule
(cond1, cond2, cond3) (desugars to) set + ~ conjunction Conjunction expression (comma sugar), evaluates to the conjunction set node
(cond1, cond2) => cons (zelph/rule [cond1 cond2] cons) Rule using a conjunction of conditions
.run (zelph/run) Run forward chaining to a fixed point
.run-once (zelph/run-once) Run a single inference pass
.run-delta (zelph/run-delta) Run inference seeded only by the facts added since the last run
.cluster <name> (zelph/cluster "name") Activate a cluster; returns the active name, or nil for the default
.cluster-drop <name> (zelph/cluster-drop "name") Roll back everything created in the cluster; returns the node count removed
.cluster (listing) (zelph/clusters) Array of [name node-count] tuples
&42 (zelph/number "42") Number literal; delegates to the redefinable zelph/number hook
&-literal display (zelph/set-number-digits ["0" "1" ...]) Register digit alphabet; digit lists display as decimal &-literals
β‰ˆnet(A P30 X) (zelph/approx (zelph/fact 'A "P30" 'X) "net") Neural rule condition (see Neural Networks in the Graph)