Skip to content

Performance Architecture

This section of the documentation is for contributors, not users. zelph's performance machinery is invisible by design: every mechanism described here is semantically neutral, so results, deductions, and command behavior are identical with and without it. That is exactly why it appears nowhere in the user-facing pages β€” and why it is collected here instead, together with the soundness arguments that license it. The companion page Measurement Methodology documents how changes to this machinery are validated; treat the two pages as one contract.

For orientation, a snapshot of what the machinery buys (July 2026): the symbolic-mathematics case study β€” nine partial derivatives, simplification, and polynomial compilation of a 3Γ—3 Jacobian determinant (test_jacobian.cpp) β€” took 23 and 9 minutes for its two phases when this work began, and takes 0.9 and 1.2 seconds today (roughly 1500Γ— and 480Γ—), with every semantic counter bit-identical and the full test suite permanently running in .semi-naive check mode. Every subsection below contributed to that factor.

The Identity Foundation

Everything on this page is a corollary of one design decision: a fact node's ID is the hash of its triple, create_hash(predicate, subject, objects) β€” see Internal Representation of Facts for the topology this identifies. Four consequences carry all the soundness arguments that follow:

  1. A node's genuine triple is immutable from creation: the ID pins it. Graph growth can change how a triple is reconstructed from adjacency, never what the triple is.
  2. Hash-consing materializes children before parents, so per-node bookkeeping computed bottom-up at creation time is final β€” there is no "later update" case to handle.
  3. Two equal, fully concrete structures are the same node. Concrete nodes therefore unify only via identity, which is what makes anchoring (below) complete.
  4. Hash-consed structures are acyclic: no node contains itself.

The Reconstruction Problem

zelph stores no triples; it stores topology. get_fact_structures (fact_structure.hpp) reconstructs a node's (subject, predicate, objects) readings from its adjacency: the predicate is a right neighbor in the declared relation-type set, the subject is a bidirectional neighbor, objects are pure incoming edges. The hard part is disambiguation β€” a fact node that is itself the subject of further facts acquires bidirectional neighbors that masquerade as subjects, and a heuristic that inspects up to three adjacency hops separates genuine subjects from such "child facts". Ambiguous candidate sets are pruned by hash verification: a candidate reading is genuine iff create_hash over it reproduces the node's ID (foundation fact 1 at work).

One asymmetry of the topology has to be undone by hand. fact() draws F β†’ P for the predicate and O β†’ F for an object, so both the objects of a node and the facts that use it as their predicate arrive in its incoming set, on edges that are indistinguishable locally. A node in predicate position would therefore read back with its own users appended to its objects. What separates them is the candidate's own reading β€” a user is a fact whose predicate is this very node β€” and that test is only run for nodes that are relation types to begin with, which no ordinary data node is.

This reconstruction is correct but expensive β€” O(degΒ²) on hub neighborhoods β€” and the engine consults structures on its hottest paths (unification, template rejection, grounding, anchoring). The layered lookup below exists to make the walk the exception.

The Layered Structure Lookup

get_fact_structures answers through four layers, cheapest first:

  1. Structureless bit gate (lock-free). Atoms (sequential IDs) and variables can never decompose, so !is_hash(n) || is_var(n) answers with a shared empty list before any lock or cache probe β€” two bit tests. Soundness: a structure requires a declared relation type among the node's right neighbors; every edge out of a non-hash node leads to a hash fact node by construction of connect(), and hash nodes enter the relation-type set only through an explicit (hashnode ~ ->) declaration, which neither the parser, the stdlib, nor any import produces. This is an accepted exotic divergence class, backstopped by .semi-naive check. The gate is static β€” it holds after binary loads too, so on a loaded Wikidata graph every Q/P atom answers without touching a lock.
  2. The fact-structure cache (_fs_cache). A promotion cache mapping node β†’ immutable shared structure list (FactStructurePtr). A hit costs one shared-lock pair plus one atomic refcount increment β€” no deep copy β€” and a held pointer stays valid across invalidations, referencing a consistent snapshot. All empty results share one static instance, so negative entries (the most frequent lookups on the unify recursion path) allocate nothing.
  3. The genuine-structure store (_genuine). Zelph::fact() records the exact triple of every node it creates as a one-element immutable list, at the moment the triple is known and final (foundation facts 1 and 2). Cache misses consult it before walking; hits are promoted into the fs_cache. Two deliberate exclusions: facts with subject == predicate are not stored β€” the reconstruction walk yields empty for those, and unification's atom treatment of them is pinned behavior β€” and self-facts store objects == {subject}, matching the walk's self-referential repair exactly. Because a node's ID pins its triple, store entries can never go stale through graph growth; only topology destruction disarms them (next section).
  4. The reconstruction walk. The historical semantics, kept verbatim. In normal operation it serves only subject == predicate facts; after a store disarm it serves everything β€” and it is itself fast now, running under a single ReadScope (below) instead of paying a lock pair and an adjacency copy per neighborhood probe.

Per-node cache invalidation

The fs_cache formerly suffered a wholesale clear on every created fact, keeping it near-permanently empty on rule-heavy workloads. fact() now calls invalidate_fact_structures_for, which erases only what growth can actually affect: the new relation node and its components, plus one bidirectional adjacency level around subject and objects (the neighborhood the child-fact heuristic inspects). The correctness argument rests on monotonicity: growth can only add reconstruction candidates, and hash verification prunes any ambiguous set back to the genuine reading. Two escape hatches degrade to the wholesale clear: relation-type declarations (P ~ ->), which can change predicate detection for any entry and also invalidate the memoized relation-type set, and neighborhoods exceeding a fixed stale budget (hubs). The budget philosophy recurs throughout the engine: degradation is never unsound and never worse than the old semantics. One residual risk is consciously accepted β€” entries that no candidate hash-verifies (e.g. subject == predicate readings) are not re-checked on deeper-level growth; the suite-wide .semi-naive check net backstops it.

The Template-Variable Store

_template_vars maps every created node whose structural closure contains variables to its exact variable set, maintained bottom-up by fact() from the actual triple arguments. Entries exist only for nonempty sets, so while the store is authoritative, absence means "provably no variables" β€” var_in_closure(n) is a single map probe, and collect_variables is O(1). This is the criterion separating rule-template nodes from data nodes, consumed by the deep template rejection in extract_bindings, by anchor eligibility, and by bound-pattern grounding. Unlike the former reconstruction-based walk, the store cannot be misled by ambiguous adjacency readings; it deliberately covers subject == predicate facts, whose closure variables the walk cannot see β€” the exact answer is the safer one for template-leak prevention (an accepted, documented divergence).

Authoritative Bits and the Disarm Funnel

Both stores carry an authoritative flag with a one-way discipline: disarmed stores are never re-armed. Absence of an entry is meaningful while a store is authoritative, and no retroactive scan could soundly recreate that property (.new re-arms by creating a fresh engine). The disarm funnel is the single shared implementation disable_fact_stores(), reached through invalidate_fact_structures_cache β€” trusted imports, binary loads (.load), node removals, merges, and name merges, i.e. every path that either bypasses triple-level construction or destroys topology β€” and through the explicit .fact-stores off command. Growth-only full clears (relation-type declarations, stale-budget degradation) deliberately do not touch the stores: they are growth-immune by the identity foundation.

The trade-off the switch controls is memory: roughly 150 bytes per fact()-created node. Wikidata-scale graphs are neutral by construction β€” the first trusted import or .load disarms the stores before they could grow β€” and rules typed onto a loaded billion-node graph still work normally, on the walk path, which is itself faster than it was before this project.

ReadScope: One Lock Pair per Read Region

Network::ReadScope acquires shared locks on both adjacency maps (left before right β€” the writer order of connect()) and hands out references into the maps for its lifetime, replacing sequences of get_right/get_left calls that each paid a rwlock pair plus a full adjacency_set copy. Its hard rules are absolute for any code running under a live scope: never write to the network, never take another network lock (no nested scope), and never call the locking API β€” get_right, get_left, exists, check_fact, parse_relation, format, log, or any output stream. std::shared_mutex shared-locking is not guaranteed reentrant, and a writer queued between two shared acquisitions deadlocks the process. Prefetch everything that locks (e.g. the relation-type memo) before opening the scope. Consumers today: the whole reconstruction walk, check_fact's edge probe (fact_edges_hold), anchored-candidate collection, and the partial-anchor climb.

A layering rule guards its construction: zelph_impl.hpp is included only by zelph.cpp (Cap'n-Proto layering), so Zelph::read_scope() is declared in zelph.hpp but defined in zelph.cpp, and ReadScope itself lives in Network (network.hpp). Never name Impl-nested types or dereference _pImpl in other headers β€” this is a recurring, build-breaking mistake.

Candidate Sets: Anchoring and Semi-Naive Seeding

The user-facing semantics of these features live elsewhere β€” Semantic Arithmetic introduces bound-pattern grounding and semi-naive evaluation, Stratified Evaluation covers the negation schedule, and .help .anchors / .help .semi-naive document the switches. This section records the engineering invariants.

Anchoring replaces full-relation scans with adjacency lookups from a concrete node. Subject/object-driven anchors collect a candidate's adjacency under one lock scope, rejecting rule-topology nodes via var_in_closure. Bound-pattern grounding resolves a fully bound structured pattern to the single node it denotes via pure hash lookups β€” with deliberate exact object-set semantics β€” and can fail a condition outright when the denoted fact is missing. Partial-pattern anchoring handles the partially bound case: any concrete node inside the pattern must appear identically in every matching graph fact (foundation fact 3), so climbing the adjacency levels from the lowest-degree anchor, filtered by the pattern's predicate chain, yields a complete candidate superset. Predicate positions never qualify as anchors β€” a fact points to its predicate, so the predicate's incoming side is the full extent, exactly the scan being avoided. All anchoring is budgeted, and an exceeded budget falls back to the full scan, never to a truncated candidate set: soundness is unconditional (candidates still pass structural unification) and completeness is budget-independent. .anchors off restores the anchor-free naive reference, decoupled from .parallel; tests use it as an independent completeness check.

Semi-naive evaluation builds a static per-run index (IndexedRule): each rule's seedable leaf conditions, a predicate β†’ (rule, leaf) index, a wildcard list for variable-predicate leaves, and β€” hoisted out of the Unification constructor β€” the rule-static pattern decomposition (PatternInfo: relation, subject, objects, subject-predicate hint), which is a pure function of the immutable condition node and is reused by every seed. Binding-dependent work (relation-variable resolution, grounding, boundness analysis, anchoring, snapshot launches) stays per-instance. The delta is captured by the fact-creation observer, and a seeded Unification has a candidate set of exactly one fact. Rules whose seeding cannot be proven complete (nested conjunction elements, ambiguous predicates, neural conditions) are classified delta-unsafe and run classically each iteration; rules with negation form the deferred stratum. The check mode appends classic verification passes and names any fact the delta path missed β€” the completeness net everything above is measured against.

Join ordering (optimize_order) carries a connectivity term: a condition sharing no variable with the current bindings starts an unconstrained cross-product scan and must lose against every connected condition, whatever the cardinalities. Variables at any structural depth count as connecting β€” the decisive case is a bound variable sitting inside a nested pattern, invisible to subject/object boundness scores. Guard conditions (!=, neural, negation) are pushed last via tier penalties that dominate the connectivity term.

Smaller Fast Paths

A few hot-path rewrites are worth knowing before touching their call sites. check_fact probes all edge memberships of the exact triple under one lock scope on references (Network::fact_edges_hold); the expensive hash-collision diagnostics are a cold branch that fetches its own copies. create_hash over an object set skips the copy+sort normalization whenever the set's storage mode already iterates ascending (small sets β€” nearly all of them); large unordered storage keeps the normalization so the hash stays a pure function of the element set. parse_relation prefilters right neighbors through the memoized relation-type set before running the exact probe; its ReadScope variant parse_relation_scoped uses membership (is-known) semantics, documented as exactly equivalent within reconstruction. Finally, output streams route their flush through the print mutex (locked_stream) β€” a correctness contract, not an optimization: pool workers log concurrently, and an unserialized stateful output handler is a data race.

What a Save Costs

Everywhere else on this page the budget is time. For .save it is memory, because the operation that matters β€” writing a network freshly imported from a Wikidata dump β€” runs on a machine that is already deep into swap.

A saved network is a stream of Cap'n Proto messages: a small header, then the adjacency and name maps in chunks of chunk_entries (1M) each. Cap'n Proto allocates the first segment of a message up front, so the size handed to MallocMessageBuilder is a floor on what the write needs resident, not a hint β€” and under mimalloc, which the zelph binary links, the pages are there immediately. A flat first segment of 64 Mi words therefore cost 512 MiB per message regardless of content: measured, saving an 11 kB network moved process RSS from 0.0 to 0.5 GiB, and the same half gigabyte was charged on top of every large save.

serialization_layout.hpp sizes it from the entry count of the chunk being written instead β€” eight words per entry, which covers a node plus a list header plus a typical adjacency, or a key plus a short label. Where the estimate falls short Cap'n Proto appends a segment rather than copying, each new one as large as all previous together, so a miss costs at most a factor of two in memory and never a memcpy. It does grow the file a little (a segment table entry per segment, and cross-segment references become far pointers), which is why the estimate is generous enough to keep the common chunk in one segment.

On a 2.6M-node network (0.7 GiB resident, 169 MB on disk) this takes the peak RSS of the save from 1.22 GiB to 0.84 GiB at unchanged wall time, and the file comes out byte-identical. No off switch accompanies this: unlike the acceleration stores, nothing here trades memory for speed β€” the segment is simply as large as the data going into it.

Reading the Code

The map, in dependency order: network.hpp (adjacency maps, connect, hashing, ReadScope, fact_edges_hold, collect_anchored_facts); zelph.hpp / zelph.cpp (the stores, the fs_cache and its invalidation, the relation-type memo, read_scope); zelph_impl.hpp (store members β€” included only by zelph.cpp, see the layering rule above); fact_structure.hpp (the layered lookup and the reconstruction walk); serialization_layout.hpp (chunk size and first-segment sizing, i.e. what a save costs); unification.cpp / unification.hpp (grounding, anchoring, PatternInfo, the scan loops); reasoning_seminaive.cpp (IndexedRule, the delta loop, strata, the check mode); reasoning.cpp (optimize_order); reasoning_profiler.hpp (every counter the measurement page relies on). The regression tests pinning this machinery live in src/test β€” test_check_fact.cpp, test_fact_cache.cpp, test_genuine_structure.cpp, test_var_closure.cpp, test_partial_anchor.cpp, and test_seminaive.cpp; their comments are primary sources for why each pin exists.