The substrate
contradiction-preserving · evidence-first

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 (killedByassassinatedBy), 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":

  1. 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_expansion on by default; logical-inference / export are opt-in). 19,837 active alignments today.
  2. donto_predicate_closure — a flat, materialized index (predicate, equivalent, relation, swap_direction, confidence), rebuilt periodically by donto_rebuild_predicate_closure() (a cheap TRUNCATE-then-INSERT, never per-query). 883,226 rows: self 873,603 · close_match 8,390 · exact_equivalent 1,083 · inverse_equivalent 109 · sub_property_of 29 · decomposition 12 — i.e. 9,623 real expansion edges over a self-map that lets any predicate resolve in O(1).
  3. 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 with matched_via (the relation) and alignment_confidence. donto-memory recall calls this directly, which is how "where does he work?" folds occupationworksAs where 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 with same_referent as 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_queueFOR UPDATE SKIP LOCKED leasing, 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 / statement providers follow the same shape (server-side only; workers need zero changes — they embed whatever text the lease hands them).
  • AuthAuthorization: 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/stats currently times out over HTTP — its per-target counts() runs an unbounded count(*) join over the 40M-row statement table. Needs a cached/bounded count. (Use the DB figures in §7 meanwhile.)
  • Only the memory_chunk provider is wired into the coordinator's PROVIDERS; the predicate / entity / statement providers 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.