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:
- 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.
- 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.
- Two equal, fully concrete structures are the same node. Concrete nodes therefore unify only via identity, which is what makes anchoring (below) complete.
- 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:
- 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 ofconnect(), 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. - 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. - 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 withsubject == predicateare not stored β the reconstruction walk yields empty for those, and unification's atom treatment of them is pinned behavior β and self-facts storeobjects == {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). - The reconstruction walk. The historical semantics, kept verbatim. In
normal operation it serves only
subject == predicatefacts; after a store disarm it serves everything β and it is itself fast now, running under a singleReadScope(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.