donto's Alignment Engine — and How to Help Build It
A contributor's guide to the part of donto that turns an unbounded, contradictory firehose of claims into queryable knowledge — and a one-command way to lend it your compute. Compiled 6 June 2026. All numbers verified live against the running donto-pg instance.
0. TL;DR for contributors
donto extracts knowledge by letting an LLM invent predicates freely — the live store holds 40.8M statements across ~1.07M distinct, freely-minted predicates. Nothing is collapsed at write time. The hard part — folding synonyms (killedBy ↔ assassinatedBy), resolving identities (is this "George Davis" that one?), and ranking by reality — is deferred to query time, powered by a vector embedding fabric.
That fabric is CPU-bound on a single 4-core box (bge-small at ~2–4 chunks/sec), and there's a backlog of roughly:
~2.84M entity vectors · ~63K predicate vectors · ~455K conversation chunks waiting to be embedded.
You can knock that down from your laptop or a spare GPU box. A worker never touches the database — it leases text from a coordinator, embeds it, and submits 384-dim vectors. One command:
export DONTO_EMBED_TOKEN=<ask Thomas>
docker compose up -d --build --scale embed-worker=$(nproc)
The full worker kit (worker.py · Dockerfile · docker-compose.yml · README) is at /research/donto-embed-worker/. The rest of this report explains what you'd be feeding.
1. Why align at QUERY TIME — the abundance bet
For sixty years, generating typed knowledge was the scarce step. That's over: a guided frontier model emits an essentially unbounded, multi-directional space of properties and relations about anything — inventing the predicates as it goes — for ~$0.0001 each. donto's bet is to keep all of it: every variant, every contradiction, anchored to its source, held forever as legal state.
The cost of that bet is a proliferation the system must tame later, not a problem to prevent: ~1.07M distinct predicates, most of them LLM-minted variants of one another (rdfType/rdf:type, locatedIn/located-in, killedBy/murderedBy/wasAssassinatedBy). Collapsing them at write time (pick-a-winner, dedup-on-conflict) is what a vector DB or a normal KG does — and it throws most of the firehose away.
donto does the opposite: it folds synonyms at query time, by similarity — never a hand-maintained synonym list. The rule is inscribed in the source: "There is NO hardcoded synonym list anywhere — the model supplies the semantics." That's the no-brittle-logic rule (embeddings / similarity / learned alignment, never an if/elif ladder over strings). The alignment engine is how the bet pays off.
2. The embedding fabric — pgvector + bge-small
The substrate is Postgres 16 with pgvector 0.8.2. Meaning is carried by BAAI/bge-small-en-v1.5 (via fastembed), 384-dim, pinned. Three embedding targets, each a vector(384) column with an HNSW cosine index:
| Target table | Rows embedded (live) | What the text is |
|---|---|---|
donto_predicate_embedding |
886,832 | the humanized predicate IRI — ex:assassinatedBy → "assassinated by" (strip prefix, split camel/kebab/snake, lowercase). Structure only; the model supplies meaning. |
donto_entity_embedding |
317,233 | an entity fingerprint — label + its most salient outgoing statements, salience weighted by data-driven IDF (donto_predicate_idf) so plumbing predicates sink and discriminating relations float. A sig_hash makes it incremental — only re-embed when the entity's statements actually change. |
donto_x_memory_chunk_embedding |
59,705 | conversation/episodic chunks, for donto-memory's hybrid recall (bge-small + FTS via Reciprocal Rank Fusion). |
Every write is additive / I3-safe: embeddings only insert into embedding tables — they propose relationships, they never mutate donto_statement and never merge anything.
Why bge-small, why pinned: it's small enough to run on any CPU (and fly on a GPU), 384-dim keeps the HNSW indexes cheap, and the whole fabric must use one model so vectors are comparable. Changing the model means re-embedding everything — so the model name travels with each unit of work, and workers just load whatever they're told.
3. Query-time predicate folding — ledger → closure → donto_match_aligned
Three layers turn "1.07M predicates" into "a query for occupation also finds worksAs":
donto_predicate_alignment— the bitemporal, append-only alignment ledger (valid-time + tx-time; retraction closes a row's tx-time, never deletes). Relations:exact_equivalent,inverse_equivalent,sub_property_of,close_match,decomposition,not_equivalent(+ broader/narrower/value-mapping). Per-row safety flags gate use (safe_for_query_expansionon by default; logical-inference / export are opt-in). 19,837 active alignments today.donto_predicate_closure— a flat, materialized index(predicate, equivalent, relation, swap_direction, confidence), rebuilt periodically bydonto_rebuild_predicate_closure()(a cheap TRUNCATE-then-INSERT, never per-query). 883,226 rows:self873,603 ·close_match8,390 ·exact_equivalent1,083 ·inverse_equivalent109 ·sub_property_of29 ·decomposition12 — i.e. 9,623 real expansion edges over a self-map that lets any predicate resolve in O(1).donto_match_aligned(...)— the query-time fold. It returns (a) direct matches (confidence 1.0), (b) closure equivalents, and (c) swapping inverses — filtered with subject/object swapped, then projected back so the caller always sees a well-formed(subject, predicate, object). Every row is tagged withmatched_via(the relation) andalignment_confidence. donto-memory recall calls this directly, which is how "where does he work?" foldsoccupation↔worksAswhere lexical FTS scores zero.
Embeddings feed layer 1: nearest-neighbour predicates in the HNSW index become candidate alignments, which (above a similarity threshold, optionally LLM-adjudicated) get written to the ledger. The more predicate vectors exist, the better the folding.
4. Identity as a hypothesis — never a merge
Predicate alignment folds relations; identity resolution folds entities: does symbol A co-refer with symbol B? donto's stance is that identity is a reversible, versioned hypothesis, never a destructive merge.
donto_identity_edge(124 live) — weighted, bitemporal pairwise assertions:same_referent/possibly_same_referent/distinct_referent/not_enough_information, each with confidence,method(trigram, embedding, neural, human, import, rule) and an evidence trail. Crucially,distinct_referent("cannot-link") coexists withsame_referentas legal state — contradictions are held, not resolved.donto_identity_hypothesis(13) +donto_identity_membership(123) — versioned clustering solutions over those edges, each with its own thresholds. Three ship: strict (≥0.98), likely (≥0.85), exploratory (≥0.60). The same data yields different clusters under different policies; a new hypothesis supersedes an old one rather than overwriting it.donto_identity_cluster_cache(1,026) — the materialized clusters, rebuilt periodically.donto_identity_proposal(85) — reversible ANN-generated candidates (entity embeddings → nearest neighbours → proposed, never auto-merged).
Entity embeddings are what make this work — they're the candidate generator. The 2.84M unembedded entities are 2.84M referents that can't yet be matched to their duplicates.
5. The continuous alignment daemon
donto-align-daemon.service (healthy, 192 ticks, 600s heartbeat) orchestrates the proven tools on a loop — it doesn't reinvent them, and each step is bounded + wrapped so one failure never kills the tick:
- embed new predicates + entities (bge-small) → propose predicate alignments (ANN + optional LLM adjudication) → propose identity edges → rebuild the closure + cluster caches periodically.
- It's cap-aware (≤300 GLM calls/window, backs off on the weekly cap), load-aware (pauses above load 4.0 / during active extraction), and idempotent. A real fix baked in: entity-embed batches are sorted by IRI so concurrent ticks acquire row locks in the same order (no deadlock). It currently runs
dry_run— proposing, not auto-committing — so a human/threshold gate stays in the loop.
The daemon is the brain; it is bottlenecked by the muscle — embedding throughput. That's where you come in.
6. The distributed embedding fabric — coordinator + workers
To scale embedding from one box to many machines, donto runs a tiny coordinator (donto-embed-coordinator.service, aiohttp + asyncpg, 127.0.0.1:7930, public via Caddy at https://donto.org/embed/*). Workers are deliberately dumb and stateless: lease → embed → submit. They hold no DB credentials and see only text.
- Work queue
donto_embed_queue—FOR UPDATE SKIP LOCKEDleasing, so any number of concurrent workers/machines take disjoint work with zero double-embedding. A crashed worker's in-flight items auto-return after a ~15-min stale-lease TTL — nothing is lost. - Provider-per-target — v1 serves
memory_chunk;predicate/entity/statementproviders follow the same shape (server-side only; workers need zero changes — they embed whatever text the lease hands them). - Auth —
Authorization: Bearer <token>on every endpoint except/embed/health. The token is distributed privately; it is not in this report or the worker repo. - Dimension-validated — the coordinator drops any vector that isn't exactly 384-dim, so a misconfigured worker can't corrupt the fabric.
Worker protocol (what your container speaks)
| Endpoint | Auth | Purpose |
|---|---|---|
GET /embed/health |
no | {"ok":true,"targets":["memory_chunk"]} |
GET /embed/stats |
yes | per-target {source, embedded, queue:{pending,leased,done}} |
POST /embed/enqueue |
yes (operator) | fill the queue: {target, limit} → {enqueued} |
POST /embed/lease |
yes | {worker_id, n, target?} → {batch:[{target,item_id,text,model,dim}]} (n ≤ 1024) |
POST /embed/submit |
yes | {worker_id, items:[{target,item_id,vector:[…384]}]} → {upserted} |
The loop: lease a batch → fastembed(bge-small) the text fields → submit the 384-dim vectors → repeat; sleep when empty. That's the entire worker.
7. The backlog you'd knock down (live, 6 Jun 2026)
| What's embedded | Count | What's waiting | Count |
|---|---|---|---|
| predicate vectors | 886,832 | unembedded entities | 2,837,699 |
| entity vectors | 317,233 | unembedded predicates | 63,074 |
| memory-chunk vectors | 59,705 | BEAM conversation chunks | ~454,648 |
| closure expansion edges | 9,623 | memory-chunk coverage | ~10.7% |
On the single 4-core box, bge-small runs ~2–4 chunks/sec — so this backlog is months of CPU. A handful of contributor cores (or one consumer GPU at hundreds–thousands/sec) collapses it to days or hours. Every vector you add directly improves predicate folding, identity resolution, and memory recall across the whole substrate.
8. Contribute — the worker
Everything you need is in /research/donto-embed-worker/ (worker.py, Dockerfile, docker-compose.yml, README.md).
# 1. get the token from Thomas (sent privately)
export DONTO_EMBED_TOKEN=<token>
# 2. run one worker per core
docker compose up -d --build --scale embed-worker=$(nproc)
docker compose logs -f # watch +256 (total …)
# …or bare metal
pip install fastembed # fastembed-gpu for NVIDIA
DONTO_EMBED_URL=https://donto.org/embed python worker.py
The worker, in full (it really is this small):
batch = post("/lease", {"worker_id": WID, "n": N, "target": TARGET})["batch"]
vecs = model_for(b["model"]).embed([b["text"] for b in batch]) # bge-small, 384-dim
post("/submit", {"worker_id": WID,
"items": [{"target": b["target"], "item_id": b["item_id"],
"vector": [float(x) for x in v]} for b, v in zip(batch, vecs)]})
Safety / FAQ. Your worker (a) never connects to Postgres or sees the corpus — only the text it must embed; (b) can be stopped anytime — unsubmitted leases reclaim in ~15 min, nothing double-embeds; (c) must keep the model at BAAI/bge-small-en-v1.5 (the coordinator names it per lease, so just don't override it); (d) needs only Python 3.9+, fastembed, and outbound HTTPS. A GPU (fastembed-gpu, EMBED_N=1024, --gpus all) is 100–1000× a CPU core.
9. Known issues (contributions welcome here too)
GET /embed/statscurrently times out over HTTP — its per-targetcounts()runs an unboundedcount(*)join over the 40M-row statement table. Needs a cached/bounded count. (Use the DB figures in §7 meanwhile.)- Only the
memory_chunkprovider is wired into the coordinator'sPROVIDERS; thepredicate/entity/statementproviders are designed but not yet registered, and the queue isn't bulk-enqueued yet — so step one for the first contributor session is the operator running/embed/enqueue(and wiring the entity provider) to materialize the 2.84M-entity backlog into the queue.
If you want to go deeper than embedding — the alignment ledger, the identity hypotheses, the daemon, the closure rebuild — that's all open too. Ping Thomas. We can figure out all understanding together.