Skip to content

Ontology & Reasoning

An ontology captures meaning about your data—relationships that exist at the type level, not just instance level. You need ontology when:

  • Type hierarchies: “A Podcast is a type of Media” (query for Media, get Podcasts too)
  • Concept relationships: “Machine Learning is narrower than AI” (topic navigation)
  • Constraints: “A Person cannot also be an Organization” (prevent invalid data)
  • Edge implications: Query knows through more-specific marriedTo rows when explicitly requested
  • Bidirectional queries: “manages and managedBy are inverses” (traverse in either direction)

Without ontology, you’d implement these manually—if statements scattered throughout your code, hand-rolled validation, duplicate queries. Ontology centralizes this logic in your schema.

TypeGraph treats semantic relationships between types as meta-edges—edges at the type level rather than instance level:

// Instance edges: relationships between INSTANCES
// "Alice knows Bob"
const knows = defineEdge("knows");
// Meta-edges: relationships between TYPES
// "Employee subClassOf Person"
subClassOf(Employee, Person);

When you define an ontology, TypeGraph:

  1. Precomputes closures at store initialization (not query time)
  2. Expands only the query operations that explicitly opt in (except inverse traversal, whose store default is "inverse" and can be changed)
  3. Enforces the documented constraints when building a registry or writing data

It does not run a general reasoner, materialize implied edges, substitute properties between types, or automatically expand every query.

Relation / feature Runtime contract
subClassOf Transitive registry closure, write-path endpoint assignability, and opt-in node-query expansion with includeSubClasses
disjointWith Same-ID collision enforcement, propagated through interleaved subClassOf and equivalentTo closure (sameAs remains a deprecated equivalence alias)
implies Transitive registry closure and opt-in traversal expansion with expand: "implying"; endpoints are validated
inverseOf Single inverse partner, endpoint reversal validation, and traversal expansion with expand: "inverse" (the default store setting)
equivalentTo Registry lookups and graph-merge type reconciliation; no automatic query or property behavior. sameAs is folded in as a full alias — the merge type reconciler and the registry treat a sameAs declaration identically to equivalentTo
broader / narrower Transitive registry introspection only
partOf / hasPart Transitive registry introspection only
relatedTo Symmetric direct registry introspection through getRelatedKinds only
Type-level sameAs Deprecated name for equivalentTo (see above); prefer calling equivalentTo directly
Type-level differentFrom Deprecated and decorative — never enforced instance identity; migrate to the graph-level TypeGraph Identity Profile
Custom metaEdge() properties Serialized introspection metadata only; custom transitivity, symmetry, inverse, and inference settings are not executed

TypeGraph provides a standard set of meta-edges:

import { subClassOf, broader, narrower, equivalentTo, sameAs, differentFrom, disjointWith, partOf, hasPart, relatedTo, inverseOf, implies } from "@nicia-ai/typegraph";

subClassOf: Defines type inheritance where instances of the child are also instances of the parent.

subClassOf(Podcast, Media);
subClassOf(Article, Media);
subClassOf(Company, Organization);

Query Behavior:

Subclass expansion is opt-in via includeSubClasses: true:

// Without expansion: returns only nodes with kind="Media"
const mediaOnly = await store
.query()
.from("Media", "m")
.select((ctx) => ctx.m)
.execute();
// With expansion: returns Media, Podcast, AND Article nodes
const allMedia = await store
.query()
.from("Media", "m", { includeSubClasses: true })
.select((ctx) => ctx.m)
.execute();
// Results include nodes of kind "Media", "Podcast", and "Article"

This is a fundamental difference from traditional ORM inheritance—TypeGraph stores the concrete type (kind: "Podcast") in the database, and expands at query time when requested.

broader and narrower: Define conceptual hierarchy without identity.

broader(MachineLearning, ArtificialIntelligence);
broader(DeepLearning, MachineLearning);
broader(ArtificialIntelligence, Technology);

Important: This is different from subClassOf. A topic instance of “ML” is related to “AI”, but is not an instance of “AI”.

// Get all topics narrower than Technology
const narrowerTopics = registry.expandNarrower("Technology");
// ["ArtificialIntelligence", "MachineLearning", "DeepLearning", ...]

equivalentTo: Defines semantic equivalence between types or external IRIs.

equivalentTo(Person, "https://schema.org/Person");
equivalentTo(Organization, "https://schema.org/Organization");

sameAs and differentFrom are deprecated type-level factories. sameAs is currently a type-equivalence alias; differentFrom is decorative. For durable individual identity, enable the graph-level TypeGraph Identity Profile and use store.identity. That ledger deliberately does not provide OWL property substitution or automatic graph-wide query expansion.

disjointWith: Declares that two types cannot share the same ID.

disjointWith(Person, Organization);
disjointWith(Podcast, Article);

Disjointness is inherited by subclasses. If Company subClassOf Organization, then disjointWith(Person, Organization) also makes Person and Company disjoint.

Effect: Attempting to create a node that violates disjointness throws DisjointError:

// Create a Person with ID "entity-1"
await store.nodes.Person.create({ name: "Alice" }, { id: "entity-1" });
// Throws DisjointError: Person and Organization are disjoint
await store.nodes.Organization.create({ name: "Acme" }, { id: "entity-1" });

Coherence rules: disjointWith cannot contradict the rest of the ontology. A kind disjoint with itself, a kind disjoint with one of its own subclass ancestors, a common subclass of two disjoint parents, and a kind declared both equivalentTo and disjointWith another are all rejected, including overlaps reached through mixed equivalence/subclass paths. These checks run both when you construct a graph and when a persisted schema is reloaded, so a document written by an older, more permissive version can fail validation on load with a ConfigurationError whose details code is ONTOLOGY_DISJOINT_CONFLICT. To recover, fix the graph definition and, for a persisted schema, correct the stored document before upgrading (or rewrite it through the previous minor version, which still accepts it). The same construction-and-reload rule applies to the other ontology coherence checks (duplicate relations, hierarchical self-loops and cycles, and inverse-partner uniqueness).

partOf and hasPart: Define compositional relationships.

partOf(Chapter, Book);
hasPart(Book, Chapter);
partOf(Episode, Podcast);
hasPart(Podcast, Episode);

inverseOf: Declares two edge kinds as inverses of each other.

inverseOf(manages, managedBy);
inverseOf(cites, citedBy);
inverseOf(follows, followedBy);

Effect: You can query in either direction using the registry:

const inverse = registry.getInverseEdge("manages"); // "managedBy"

You can also expand traversals to include inverse edge kinds at query time:

const relationships = await store
.query()
.from("Person", "p")
.traverse("manages", "e", { expand: "inverse" })
.to("Person", "other")
.select((ctx) => ({
other: ctx.other.name,
via: ctx.e.kind,
}))
.execute();

For symmetric relationships, declare an edge as its own inverse:

inverseOf(collaboratesWith, collaboratesWith);

An edge may have only one distinct inverse partner. Every allowed pair must be compatible with a reversed pair in its partner, in both traversal directions, using equal kinds or subClassOf assignability. Matching the independent source and target unions is insufficient for source-dependent edges. A self-inverse edge must satisfy the same reversed-pair check against itself.

implies: Declares that one edge kind implies another exists.

implies(marriedTo, knows);
implies(bestFriends, friends);
implies(friends, knows);

Effect: Query for knows can include marriedTo, bestFriends, and friends edges:

const connections = await store
.query()
.from("Person", "p")
.traverse("knows", "e", { expand: "implying" })
.to("Person", "other")
.select((ctx) => ctx.other)
.execute();

Endpoint compatibility is required. implies(edgeA, edgeB) only makes sense if every node kind edgeA can connect could also, in principle, satisfy edgeB’s own domain/range — otherwise expand: "implying" would traverse rows whose kinds don’t match what the traversal actually asked for. Every allowed pair in edgeA must match a single allowed pair in edgeB: both endpoints must be assignable — equal, or a subClassOf descendant — to their corresponding endpoint in that pair. For source-dependent targets, finding the source in one entry and the target in another does not suffice. An incompatible pair (say, Author -> Paper implying Paper -> Topic) throws ConfigurationError wherever the graph is built into a store or committed as a schema version (createStore, createStoreWithSchema, store.evolve({ ontology })) — including relations authored through a graph extension, not just implies() calls in code.

const graph = defineGraph({
id: "knowledge_base",
nodes: { ... },
edges: { ... },
ontology: [
// Type hierarchy
subClassOf(Podcast, Media),
subClassOf(Article, Media),
subClassOf(Company, Organization),
// Concept hierarchy
broader(MachineLearning, ArtificialIntelligence),
broader(DeepLearning, MachineLearning),
// Constraints
disjointWith(Person, Organization),
disjointWith(Media, Person),
// Composition
partOf(Episode, Podcast),
// Edge relationships
inverseOf(cites, citedBy),
implies(marriedTo, knows),
],
});

The type registry (accessed via store.registry) provides methods to query the ontology:

const registry = store.registry;
// Subsumption
registry.isSubClassOf("Podcast", "Media"); // true
registry.expandSubClasses("Media"); // ["Media", "Podcast", "Article"]
// Hierarchy
registry.expandNarrower("Technology"); // ["AI", "ML", "DL", ...]
registry.expandBroader("DeepLearning"); // ["ML", "AI", "Technology"]
// Constraints
registry.areDisjoint("Person", "Organization"); // true
registry.getDisjointKinds("Person"); // ["Organization", "Media", ...]
// Edge relationships
registry.getInverseEdge("cites"); // "citedBy"
registry.getImpliedEdges("marriedTo"); // ["knows"]
registry.getImplyingEdges("knows"); // ["marriedTo", "bestFriends", "friends"]
registry.getRelatedKinds("MachineLearning"); // ["DataScience", ...]

Define domain-specific meta-edges for serialized introspection metadata:

import { metaEdge } from "@nicia-ai/typegraph";
// Custom meta-edge for prerequisite relationships
const prerequisiteOf = metaEdge("prerequisiteOf", {
transitive: true,
inference: "hierarchy",
description: "Learning prerequisite (Calculus prerequisiteOf LinearAlgebra)",
});
// Custom meta-edge for superseding relationships
const supersedes = metaEdge("supersedes", {
transitive: true,
inference: "substitution",
description: "Replacement relationship (v2 supersedes v1)",
});

Each custom meta-edge can carry these properties as metadata. In the current release they do not make the registry compute a custom closure or make the query builder execute custom inference. Only the built-in relations in the support matrix have runtime behavior.

Property Type Description
transitive boolean A→B, B→C implies A→C
symmetric boolean A→B implies B→A
reflexive boolean A→A is always true
inverse string Name of inverse meta-edge
inference InferenceType How this affects queries

For custom meta-edges, inference is descriptive metadata for consumers:

Type Description
"subsumption" Query for X includes instances of subclasses
"hierarchy" Enables broader/narrower traversal
"substitution" Can substitute equivalent types
"constraint" Validation rules
"composition" Part-whole navigation
"association" Discovery/recommendation
"none" No automatic inference

TypeGraph precomputes transitive closures at store initialization:

// subClassOf closure
// If: Podcast subClassOf Media, Episode subClassOf Media
// Then: expandSubClasses("Media") = ["Media", "Podcast", "Episode"]
// implies closure
// If: marriedTo implies partneredWith, partneredWith implies knows
// Then: getImpliedEdges("marriedTo") = ["partneredWith", "knows"]

This makes queries efficient—expansion happens at query compilation time, not execution time.

These have different semantics:

  • subClassOf: Type membership (a Podcast instance is also a Media instance)
  • broader: Conceptual relation (ML relates to AI, but ML instance ≠ AI instance)
// CORRECT: Type hierarchy
subClassOf(Podcast, Media);
// CORRECT: Concept hierarchy
broader(MachineLearning, ArtificialIntelligence);
// WRONG: Don't mix them
// subClassOf(MachineLearning, ArtificialIntelligence);

Prevent impossible combinations:

// Good: Prevent ID conflicts
disjointWith(Person, Organization);
disjointWith(Person, Product);
disjointWith(Organization, Product);
// Relationship hierarchy: specific → general
implies(marriedTo, partneredWith);
implies(partneredWith, knows);
implies(parentOf, relatedTo);
implies(siblingOf, relatedTo);
implies(relatedTo, knows);
inverseOf(manages, managedBy);
inverseOf(follows, followedBy);
inverseOf(cites, citedBy);

This lets you query efficiently in either direction without duplicating edges.

Declares type inheritance.

function subClassOf(child: NodeType, parent: NodeType): OntologyRelation;

Declares hierarchical relationship (narrower concept to broader concept).

function broader(narrower: NodeType, broader: NodeType): OntologyRelation;

Declares hierarchical relationship (broader concept to narrower concept).

function narrower(broader: NodeType, narrower: NodeType): OntologyRelation;

Declares semantic equivalence between types or with external IRIs.

function equivalentTo(
a: NodeType | string,
b: NodeType | string
): OntologyRelation;

Deprecated type-level alias of equivalentTo, including the equivalence with external IRIs. Migrate to the graph-level TypeGraph Identity Profile for individual identity.

function sameAs(kindA: NodeType, kindBOrIri: NodeType | string): OntologyRelation;

Deprecated decorative type-level relation. Migrate to the graph-level TypeGraph Identity Profile for individual identity.

function differentFrom(a: NodeType, b: NodeType): OntologyRelation;

Declares mutual exclusion (types cannot share the same ID).

function disjointWith(a: NodeType, b: NodeType): OntologyRelation;

Declares compositional relationship (part to whole).

function partOf(part: NodeType, whole: NodeType): OntologyRelation;

Declares compositional relationship (whole to part).

function hasPart(whole: NodeType, part: NodeType): OntologyRelation;

Declares a symmetric association available through registry.getRelatedKinds(kind). It has no query behavior.

function relatedTo(a: NodeType, b: NodeType): OntologyRelation;

Declares edge types as inverses of each other.

function inverseOf(edgeA: AnyEdgeType, edgeB: AnyEdgeType): OntologyRelation;

Declares that one edge type implies another exists.

function implies(edgeA: AnyEdgeType, edgeB: AnyEdgeType): OntologyRelation;

Each allowed pair in edgeA must be assignable to one allowed pair in edgeB (equal, or a subClassOf descendant, on both endpoints). Throws ConfigurationError when the graph is built into a store or committed as a schema version if they aren’t — see Edge Relationships above.

Creates a custom meta-edge for domain-specific relationships.

function metaEdge(
name: string,
options?: {
transitive?: boolean;
symmetric?: boolean;
reflexive?: boolean;
inverse?: string;
inference?: InferenceType;
description?: string;
},
): MetaEdge;

The type registry is available via store.registry and provides methods to query the ontology at runtime.

Checks if a type is a subclass of another.

registry.isSubClassOf(child: string, parent: string): boolean;
registry.isSubClassOf("Podcast", "Media"); // true

Returns a type and all its subclasses.

registry.expandSubClasses(type: string): readonly string[];
registry.expandSubClasses("Media"); // ["Media", "Podcast", "Article"]

Checks if two types are disjoint.

registry.areDisjoint(a: string, b: string): boolean;
registry.areDisjoint("Person", "Organization"); // true

Returns all types disjoint with the given type.

registry.getDisjointKinds(type: string): readonly string[];
registry.getDisjointKinds("Person"); // ["Organization", "Media", ...]

Returns all types narrower than the given type (via broader relationships).

registry.expandNarrower(type: string): readonly string[];
registry.expandNarrower("Technology"); // ["AI", "ML", "DeepLearning", ...]

Returns all types broader than the given type.

registry.expandBroader(type: string): readonly string[];
registry.expandBroader("DeepLearning"); // ["MachineLearning", "AI", "Technology"]

Returns the inverse of an edge type.

registry.getInverseEdge(edgeType: string): string | undefined;
registry.getInverseEdge("manages"); // "managedBy"

Returns edges implied by an edge type.

registry.getImpliedEdges(edgeType: string): readonly string[];
registry.getImpliedEdges("marriedTo"); // ["knows"]

Returns edges that imply an edge type.

registry.getImplyingEdges(edgeType: string): readonly string[];
registry.getImplyingEdges("knows"); // ["marriedTo", "bestFriends", "friends"]

Returns an edge type and all edges that imply it.

registry.expandImplyingEdges(edgeType: string): readonly string[];
registry.expandImplyingEdges("knows"); // ["knows", "marriedTo", "bestFriends", "friends"]