horon-engine
Store data as a tree. Query it as a space.
The idea
Organize data in paths, like files in folders. The engine embeds that tree in hyperbolic space, so structural similarity becomes spatial proximity. One query primitive answers most questions: "what's nearby?"
Why hyperbolic space fits trees
Hyperbolic space grows exponentially with radius. Trees grow exponentially with depth. The match is exact: the engine places every node with Sarkar's construction, items in the same branch cluster, items in distant branches sit far apart. Placing a leaf is O(1) geometric work — one Möbius reflection, and no existing coordinate moves. Proof: PROOF.md, which is also candid about where the shipped embedding falls short of the theorem's hypothesis.
In production
A production course recommender runs on this engine: 183 courses and 741 students in one tree file. Recommendations are geometric, courses close to a student's enrollment history rather than keyword matches. The same file reveals miscategorized courses, demand gaps, and domains that share student populations.
Quick start
use horon_engine::Store;
let store = Store::new();
// Tree storage; parents are auto-created
store.put("/courses/trauma/emdr_basis", b"EMDR Basiscursus").unwrap();
store.put("/courses/trauma/emdr_kind", b"EMDR Kind & Jeugd").unwrap();
store.put("/courses/systemisch/eft", b"Emotion Focused Therapy").unwrap();
// Retrieval and hierarchy
let data = store.get("/courses/trauma/emdr_basis").unwrap();
let kids = store.children("/courses/trauma").unwrap();
// Spatial query: structurally nearby nodes.
// emdr_kind comes first (same branch), then eft (sibling branch).
let neighbors = store.neighbors("/courses/trauma/emdr_basis", 3).unwrap();
// Metadata
store.set_meta("/courses/trauma/emdr_basis", "capacity", "24").unwrap();
Semantic dimensions
Any node can carry a coordinate vector encoding domain meaning: categories, popularity, demand signals. Queries then run over any slice of those dimensions, and different slices answer different questions from the same data.
// Attach a 40-dimension coordinate vector (Q64.64, 16 bytes per dim)
let coords: Vec<u8> = encode_my_dimensions(...);
store.set_semantic("/courses/trauma/emdr_basis", coords.clone()).unwrap();
// The 5 nearest nodes by dimensions 16..33 (category axes),
// as Vec<(path, distance)> sorted by distance across exactly those dims
let similar = store.nearest_semantic(&coords, 5, 16..33).unwrap();
// Distance between two coordinate vectors, no store involved
let dist = Store::semantic_distance(&coords_a, &coords_b, 16..33);
This is how a catalog separates an item's labeled category from where its population actually positions it. The gap between the two is a miscategorization, visible in geometry and invisible in metadata.
Install
[dependencies]
horon-engine = "0.6"
All arithmetic is gMath Q64.64 fixed point. The determinism contract is defined on the embedded profile, so build with:
GMATH_PROFILE=embedded cargo build
API reference
All methods take &self. Share freely via Arc<Store> across threads.
Per-symbol truth lives in the docblocks: cargo doc --open.
| Method | What it does |
|---|---|
put(key, data) |
Insert or update. Auto-creates parent nodes. |
put_data_only(key, data) |
Insert without a geometric embedding. The cheap bulk path. |
embed_existing(key) / embed_all(prefix) |
Upgrade data-only keys to full embeddings, in place. |
get(key) |
Retrieve data by path. |
remove(key) |
Delete a node. |
exists(key) |
Check existence. |
children(path) |
List direct children. |
list(prefix) |
List the full subtree. |
set_meta(key, k, v) / get_meta(key) |
Per-node key-value metadata. |
nearest(coords) |
True nearest node to a point. Exact: rings expand until a proven bound rules out the rest. |
nearest_k(coords, k) |
The k nearest nodes to a point. |
neighbors(key, k) |
The k nearest neighbors of a stored node. |
find_within(key, r) |
All nodes within hyperbolic radius r. |
position(key) |
A stored node's Poincare coordinates. |
set_semantic(key, coords) / get_semantic(key) |
Attach or read dimensional coordinates (raw Q64.64 bytes). |
nearest_semantic(coords, k, range) |
k nearest by Euclidean distance across a dimension slice. |
neighbors_semantic(key, k, range) |
Semantic neighbors of a stored node. |
find_similar(key, k, range) |
"What's like this one?": task-shaped name for neighbors_semantic. |
find_outliers(prefix, z, range) |
Nodes anomalously far from their peers under a prefix (average k-NN distance, z-score). |
semantic_distance(a, b, range) |
Euclidean distance between two coordinate vectors. |
SemanticDisk::build(spec) |
Embed a concept taxonomy, derived from the data's own category tree, into a second Poincare disk. |
disk.concept_of(store, key) |
Which concept a node belongs to right now, from its affinity dims. The miscategorization primitive. |
disk.nearest(store, key, k) |
k nearest nodes in taxonomy-aware meaning-space. |
disk.classify_trajectory(...) |
Turn a Horon HoronHistory trajectory into a symbolic concept sequence across epochs. |
query(adapter, query) |
Execute a pluggable query via the QueryAdapter trait. |
len() / is_empty() |
Node count. |
HTTStorage, the layer below
Store wraps HTTStorage. Reach for it when you need direct control over the
embedding dimension:
use horon_engine::{HTTStorage, HTTStorageConfig};
let storage = HTTStorage::new(HTTStorageConfig {
dimension: 8,
max_memory_nodes: 50_000,
cache_size: 5_000,
..Default::default()
});
storage.store("/data", b"value", Some("text/plain".into())).unwrap();
QueryAdapter
Pluggable query interface for building custom query languages on top of the store:
use horon_engine::store::{QueryAdapter, QueryResult};
impl QueryAdapter for MyAdapter {
fn execute(&self, store: &Store, query: &str) -> Result<Vec<QueryResult>, StoreError> {
// Parse query, call store methods, return results
}
}
Concurrency
- Reads are lock-free:
get,exists,children,get_meta, and all spatial queries. No reader ever blocks another reader. - Writes stripe on the parent node: 64 lock stripes, so independent subtrees write in parallel.
- The spatial index shards on cell id; concurrent inserts, deletes and queries touch only the cells they need.
- There is no outer lock. Every method takes
&self; wrap inArc<Store>and share across threads, async tasks, or HTTP handlers.
How it works
Traditional trees scale lookups with depth. Spatial indexes do not understand hierarchy. Four mechanisms remove the choice:
- Sarkar embedding. Every node gets a position in the Poincare disk; children sit at hyperbolic distance tau from their parent via Mobius reflection. Position is derived from tree shape, never stored.
- Path lookup. Path to node is an O(1) HashMap access, entirely independent of the spatial index.
- The cell index. The disk is cut into radial bands (by squared norm) and angular sectors (by a diamond pseudo-angle), both sized so cells stay comparable in hyperbolic terms as the disk stretches toward the boundary. A query reads its own cell, then walks rings outward, and stops only when a proven lower bound rules out every cell it has not visited. Exact — no candidate cap, no window, no count-based stopping rule. Where the older grid returned a tile owner and hoped, this one either proves it may stop or keeps looking.
- Semantic dimensions. Orthogonal to the spatial embedding: Euclidean distance over user-defined dimension slices, no tree change required.
Determinism
All geometry is gMath Q64.64 fixed point. There are no floats in the compute path. The same operation sequence produces bit-identical state on any platform; that property is what lets a write-ahead log double as a replication protocol. CI runs the suite on x86-64 and arm64.
Architecture
ARCHITECTURE.md is the orientation document: the three positions a node can have (structural, semantic, concept) and which query serves each, the three independent things called "modes", the layer stack, and where the limits below come from. Read it before the source.
Store <- public API, all &self, Arc-shareable
└─ HTTStorage <- path normalization, CRUD, striped parent locks
└─ HyperbolicTreeTensor <- path-to-signature maps (DashMap)
└─ HyperbolicTensorNetwork <- Sarkar embedding, spatial index
├─ CellIndex <- radial bands x angular sectors, exact
├─ SemanticIndexCache <- epoch-invalidated per-slice VP-trees
└─ PoincareDisk <- hyperbolic geometry, Mobius transforms
Limits worth knowing
Stated plainly, because they affect how you should configure the engine.
-
taumust scale with fan-out. PROOF.md's Delaunay guarantee holds only whentau >= -log(tan(pi / (2 * d_max)))for the tree's maximum node degree. The defaulttau = 1.0satisfies that up tod_max = 4. Exceeding it is warned about, once per parent, on the crossing — since 0.6.0 no query path depends on the Delaunay identity, so it costs spacing quality (crowded siblings, more nodes per cell, longer scans) and never correctness. Set tau withStoreConfig::tau()for wider trees, remembering that a larger tau spends the depth budget faster:tau = 5.094carries 256 children but caps depth at 4. -
Depth is capped, and the cap is enforced. A node sits at hyperbolic radius
depth * tau, and past a radius of 21 the Q64.64 distance kernel saturates — every node the same distance from every other, ranking arbitrary. Placement beyond it is refused rather than silently accepted. Callmax_depth()for the limit at yourtau: 21 at the default 1.0, 26 at 0.8, 10 at 2.0. Full metric fidelity ends earlier still, around radius 17; between the two, ranking holds while absolute distances drift. -
nearestis not O(1). It is a cell lookup plus a ring expansion that widens until a proven lower bound rules out every unvisited cell. Exact always; the number of cells scanned depends on how the tree is shaped. The O(1) grid that preceded it was removed in 0.6.0 — it could not name a node whose cell was smaller than a grid tile, which Sarkar placement causes within a few levels. -
Extreme fan-out weakens angular spacing. On the order of 1000+ siblings under one parent, position signatures can quantize to the same slot; insertion probes forward to the next free one, so placement stays correct but the golden-angle spacing guarantee softens. Keep realistic tree shapes.
-
The engine sets no ceiling on semantic dimensions. It validates only that a coordinate vector is a whole number of Q64.64 values (a multiple of 16 bytes); distances run over any slice. The familiar 255-per-node limit — 16 reserved for access bands, 239 user-defined — is Horon's
.httheader, not this crate's. Storing wider vectors here works and will not round-trip through a.htt. -
The engine is in-memory and single-process. Durability, the on-disk layout, and cross-process readers live in Horon, whose
HoronReaderopens a.httread-only and unlocked alongside a writer. In-process concurrency is the engine's own: every method takes&self, reads are lock-free, writes stripe on the parent node, and 8 threads sustain ~4.5M reads/sec — see Concurrency.
Performance
Measured 2026-07 on an i7-7700 (4c/8t) with GMATH_PROFILE=embedded. Full
tables, machine specs, and reproduction commands:
BENCHMARKS.md.
| Operation | Measured cost | vs 0.5.x |
|---|---|---|
get |
~0.9 µs | — |
exists |
~0.1 µs | — |
put (into an existing populated tree) |
~1.2 µs | — |
put (fresh flat tree, n ≤ 100) |
46.8–95.6 µs/node | 37–150× |
remove |
7.9–30.7 µs/op | 25–98× |
nearest (full API) |
51–99 µs, n = 10…200 | 3–5× |
nearest at the origin |
7.6 µs | 10.8× |
neighbors (k = 1…5, full API) |
38–223 µs | 19–55× |
nearest_semantic (lazy per-slice VP-tree, warm) |
~283 µs at 10k nodes, ~309 µs at 100k, d=8 | — |
nearest_semantic (first query after a semantic write) |
~1.2 s at 10k nodes; index rebuild, amortizes after ~7 queries | — |
Structural rows re-measured 2026-08-22 for 0.6.0 on the machine above. Semantic rows are unchanged by that release.
Full-API queries pay for exact hyperbolic verification of every surviving candidate — that is the guarantee, not an inefficiency. The index's job is to make the number of survivors small, and the ring bound is what proves it may stop. Concurrent read throughput: ~2.6M reads/sec on 8 threads.
Read the nearest comparison honestly: the 0.5.x figure is the cost of an
answer that was frequently wrong, so it measures the replacement of a broken
thing rather than a tuning win.
Persistence
The engine is in-memory. Horon persists
it: the .htt single-file format with WAL durability, zstd compression,
snapshot compaction, and geometric access control.
use horon::Horon;
let htt = Horon::open("data.htt")?;
htt.put("/config/db", b"postgres://localhost")?;
htt.compact()?;
Ecosystem
- gMath: Q64.64 fixed-point arithmetic with ZASC-Binary transcendentals.
- Horon:
.httWAL persistence over this engine.
Recent work
- Semantic disk: the concept taxonomy embedded hyperbolically, positions derived from affinity dimensions (docs/SEMANTIC_DISK.md).
- Semantic spatial index: lazy per-slice VP-trees, ~690x faster at 10k
nodes (docs/SEMANTIC_INDEX.md), plus
find_similarandfind_outliers. - In Horon: temporal epochs (v0.6.0) and WAL-based replication (v0.5.0).
Author
Built by Niels Erik Toren. Support addresses and contribution guidelines live in the Horon README.
Disclaimer
This software is provided "as is", without warranty of any kind, express or implied. Use of this software is entirely at your own risk. In no event shall the author or contributors be held liable for any damages arising from the use or inability to use this software.
License
Apache-2.0 (see LICENSE).