Skip to content

Ontologies

Read Knowledge graph & ontology first for the background. This page covers the practical side.

createWiki(db, {
llmProvider,
config: {
ontology: {
mode: 'strict', // global default for entities with no override
seedManifests: {
'team-alpha': {
mode: 'emergent', // per-entity override
manifest: {
node_types: [
{ type: 'person', description: 'An individual or user.' },
{ type: 'project', description: 'An ongoing initiative.' },
],
edge_types: [
{ type: 'contributes_to', source_type: 'person', target_type: 'project', description: 'Works on a project.' },
],
},
},
},
},
},
});

A seed is written to the database the first time the entity’s ontology is resolved inside a transaction (on ingest or heal). Until then, getOntologyManifest() still reports it.

await wiki.getOntologyManifest('team-alpha'); // { mode, manifest } or null
await wiki.setOntologyManifest('team-alpha', manifest, { mode: 'strict' }); // upsert
// Many entities in one transaction. With ifAbsent, an existing row is left alone.
const { written, skipped } = await wiki.setOntologyManifests(
[
{ entityId: 'tier_fact', manifest, mode: 'strict' },
{ entityId: 'tier_wisdom', manifest, mode: 'strict' },
],
{ ifAbsent: true },
);

validateManifest(manifest) is exported if you want to check a manifest before saving it.

In strict and emergent modes the extraction JSON may include a type and inline edges:

{
"facts": [{
"title": "Jane reports to Bob",
"body": "Jane reports to Bob Smith.",
"tags": [], "confidence": "certain",
"okf_type": "person",
"edges": [{ "edge_type": "reports_to", "target_title": "Bob Smith" }]
}]
}
  • okf_type is matched case-insensitively and stored with the manifest’s casing.
  • Edges are resolved by target_title within the same transaction.
  • An invalid okf_type stores the fact untyped and drops its edges. An invalid individual edge is dropped (and reported as an edge_dropped diagnostic).
  • In emergent mode the model may add ontology_updates.node_types and edge_types. These are append-only and deduplicated by type. A malformed parent_type in a proposal is dropped, not thrown.
node_types: [
{ type: 'creativework', description: 'Books, articles, specs…' },
{ type: 'design_spec', parent_type: 'creativework', description: 'A technical design.' },
]

Edges declared on creativework now also match design_spec. When several edges could match a target, the exact type wins over the parent. Validation rejects any of the following:

Problem Error message
Unknown parent Parent type not found: <slug>
Self-parent Self-parent: <type>
Two or more levels Parent chain too deep: a → b → c
Blank or non-string parent_type Ontology parent_type must be a non-empty string when present

How it behaves in detail:

  • One level, checked everywhere. The manifest is validated whenever it’s saved and whenever it’s read. setOntologyManifest rejects a chain up front. The depth check also catches 2-cycles (a → b → a).
  • Absent is fine, blank isn’t. Leaving out parent_type means “no parent”. A key that is present must be a non-empty string.
  • Parents are real types. A fact can be classified as the bare parent (creativework). There’s no abstract flag. To steer the model toward the children, say so in the parent’s description.
  • Matching works on both ends, one hop. A child satisfies an edge declared on its parent as either the source or the target. Matching never walks further than one level.
  • The trade-off. An edge declared to creativework admits every child of creativework, and there’s no way to say “the parent only”. A type that needs exact-only targets must not have children.
  • Emergent mode. The model may propose parent_type in ontology_updates. Those proposals are treated as untrusted: an invalid parent_type is dropped rather than failing the ingest. To change an existing type’s parent, call setOntologyManifest.
  • No migration needed. parent_type is optional, and manifests without it validate exactly as before.

The full design is in the ontology parent field spec.

import { useOntologyManifest, useSetOntologyManifest } from '@equationalapplications/react-llm-wiki';
function OntologySettings({ entityId }: { entityId: string }) {
const { manifest, mode, refetch } = useOntologyManifest(entityId);
const { execute, isPending } = useSetOntologyManifest();
const save = async (next) => {
await execute(entityId, next, { mode: 'strict' });
refetch(); // the read hook doesn't refresh on its own
};
// …
}

Facts that bypassed the librarian (imports, or data from before ontologies) have no type and no edges. runOntologyBackfill() types them in place:

let r;
let previousRemaining = Infinity;
do {
r = await wiki.runOntologyBackfill(entityId);
// { scanned, typed, failedValidation, edgesAdded, remaining, deferred, skipped }
if (r.remaining >= previousRemaining) break; // no progress: stop, retry on the next trigger
previousRemaining = r.remaining;
} while (r.remaining > 0);
  • Stop when a pass doesn’t lower remaining. A fact whose provider call keeps throwing (a call_error, or a thrown classify) gets no cooldown stamp and stays eligible, so a bare while (remaining > 0) loop can spin on it forever. The guard above stops on no progress; the next trigger retries.
  • You own the trigger. Running it after each sync or import is a good default. It’s safe to swallow a WikiBusyError, since the next run retries.
  • It only adds. It never creates, deletes, or rewrites facts, never overwrites an existing type, and never changes updated_at.
  • Cost: a single SELECT when there’s nothing to do. Otherwise it scans up to 25 facts per run (batchSize), in sub-batches sized from maxOutputTokens. Sub-batches that fail are halved and retried.
  • Facts it can’t classify are retried after 7 days (reported as deferred).
  • Customize the prompt with options.promptOverride or config.prompts.ontologyBackfillSystemPrompt.

A non-generative classifier can do the typing at lower cost. Add classify to the provider and opt in:

createWiki(db, {
llmProvider: { generateText, classify },
config: { ontology: { backfillClassifier: 'auto', classifyMinConfidence: 0.5 } },
});
await wiki.runOntologyBackfill('user-1'); // uses classify
await wiki.runOntologyBackfill('user-1', { classifier: 'llm' }); // force the generative path
  • Each untyped fact gets one choice question over the manifest’s node types.
  • Answers below classifyMinConfidence stay untyped.
  • Classifier mode proposes no edges. Use classifier: 'llm' when you want edges.
  • More than 255 node types, or no classify function, means the generative path is used.

Curated Thoughts uses this with a Jev classifier on Cloudflare Workers AI (typesafe/jev), behind its Type untyped facts button.

classify receives { state, questions }, where each question is choice, binary, or score. It returns { answers }, which the engine validates as untrusted input: off-list choices and out-of-range probabilities count toward failedValidation, and a classify call that throws counts toward skipped and is retried on the next pass. Answers below classifyMinConfidence are retried after the cooldown.