Ontology¶
An Ontology is the schema facts are held to: named types and predicates.
Load it from a dict, JSON, YAML, pydantic models, OWL or a live Neo4j graph;
every stage sees the same shape. Loading, validating and diffing need no model.
Fields¶
| Model | Field | Default | Meaning |
|---|---|---|---|
Ontology |
name, version |
"untitled", "0" |
|
types, predicates |
empty | keyed by name; an entry's name defaults to its key |
|
inferred, frozen_at, frozen_by |
False, None, None |
set by inference and freeze() |
|
EntityType |
description, parents, aliases |
||
keys |
() |
the predicates that together name the thing | |
Predicate |
domain |
(), meaning every type |
the types it applies to |
range |
"string" |
an entity type makes an edge; string, integer, number, float, boolean, date, datetime or quantity (a number with its unit, normalised to one unit per dimension) makes a property |
|
cardinality |
"single" |
or "multi" |
|
cardinality_scope |
() |
see Cardinality and its scope | |
qualifiers |
{} |
name to Qualifier; a bare list of names is all reconcilable |
|
required |
False |
every entity in the domain should hold a value | |
importance |
0.5 | ranks the predicate in snippets | |
label, description, aliases, examples |
|||
inverse_of, symmetric |
None, False |
see Inverse and symmetric predicates | |
Qualifier |
identity |
False |
True puts the qualifier in Fact.signature (DECISIONS #15) |
Loading¶
from openodke import Ontology
ontology = Ontology.from_dict(
{
"name": "saas-vendors",
"types": {
"Vendor": {"description": "A company that sells software.", "keys": ["legal_name"]},
"Service": {"description": "A product a vendor runs."},
},
"predicates": {
"legal_name": {"domain": ["Vendor"], "required": True},
"operates": {"domain": ["Vendor"], "range": "Service", "cardinality": "multi"},
"price": {
"domain": ["Service"],
"range": "number",
"qualifiers": {"tier": {"identity": True}, "as_of": {}},
},
},
}
)
assert ontology.predicates["operates"].is_edge_in(ontology)
assert ontology.identity_keys("price") == ("tier",)
| Source | Call | Extra |
|---|---|---|
| a dict | Ontology.from_dict(data) |
— |
| JSON | Ontology.from_json(source) |
— |
| YAML | Ontology.from_yaml(source) |
yaml |
| pydantic models | Ontology.from_pydantic(*models, name=...) |
— |
| OWL, RDFS or SKOS | Ontology.from_owl(source, *, format=None, language="en") |
rdf |
| a live Neo4j graph | Ontology.from_neo4j(driver_or_uri, *, auth=None, database=None) |
neo4j |
from_json and from_yaml read a str as the document itself when it contains
a newline or starts with {, [, ---, # or %, and as a path otherwise.
Strict loading¶
Every loader but from_pydantic, which is always strict, takes strict=True by
default: it runs validate() and raises OntologyLoadError, a ValueError, on
any error. Each problem is one line naming the key by its dotted path, and
exc.problems lists them. With strict=False, anything well-formed loads; the
two importers then warn once with OntologyImportWarning, whose .problems
holds the same lines.
from openodke import OntologyLoadError
try:
Ontology.from_dict({"predicates": {"employer": {"rnage": "Company"}}})
except OntologyLoadError as exc:
print(exc)
# predicates.employer.rnage: unknown key — did you mean 'range'?
From pydantic models¶
Each model is a type and each field a predicate. A field typed as another model
you passed is an edge; list[X], tuple[X, ...] and set[X] are multi;
X | None or a field with a default is not required. A field that cannot be
mapped, such as int | str, raises.
from datetime import date
from pydantic import BaseModel
class Company(BaseModel):
legal_name: str
class Person(BaseModel):
name: str
born: date | None = None
employer: list[Company] = []
people = Ontology.from_pydantic(Person, Company, name="people")
employer = people.predicates["employer"]
assert (employer.domain, employer.range, employer.cardinality) == (("Person",), "Company", "multi")
Importing a schema you already have¶
from_owl reads a path, a document or an rdflib Graph. What it cannot carry
over, such as restrictions, sub-properties, equivalence or union ranges, is
reported by the subject it was found on. owl:imports is not followed.
| RDF | Ontology |
|---|---|
owl:Class, rdfs:Class, skos:Concept |
a type, named by the IRI's local name |
rdfs:subClassOf, skos:broader |
parents |
owl:hasKey |
keys |
owl:ObjectProperty, owl:DatatypeProperty |
an edge, a property |
rdfs:domain, rdfs:range |
domain, range |
owl:FunctionalProperty |
single; every other property is multi |
owl:inverseOf, owl:SymmetricProperty |
inverse_of, symmetric |
rdfs:label, rdfs:comment, skos:altLabel |
label, description, aliases |
import warnings
from openodke import OntologyImportWarning
hr_owl = """
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix : <https://example.org/hr#> .
:Person a owl:Class .
:Company a owl:Class .
:employer a owl:ObjectProperty, owl:FunctionalProperty ; rdfs:domain :Person ; rdfs:range :Company .
:manages a owl:ObjectProperty, owl:TransitiveProperty ; rdfs:domain :Person ; rdfs:range :Person .
"""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
hr = Ontology.from_owl(hr_owl, name="hr", strict=False)
(warning,) = caught
assert warning.category is OntologyImportWarning
print(warning.message.problems[0])
# :manages: is a owl:TransitiveProperty, which the ontology model cannot express
assert hr.predicates["employer"].cardinality == "single"
from_neo4j reads a graph's own schema and writes nothing. A label is a type, a
relationship type is an edge (or a property when it ends at :Claim nodes),
and a node property is a property. importance comes from how often each is
used. A graph Neo4jSink wrote reads back as the shape it
wrote. Labels have no hierarchy, so there are no parents; every edge is
multi, and every qualifier reconcilable.
Validate¶
ontology.validate() returns a list of Diagnostic(code, path, message,
severity) and never raises. An error produces wrong or missing facts, and
strict loading refuses it. A warning marks a schema that works but is
probably not what was meant.
| Code | Severity | Found when |
|---|---|---|
name-mismatch |
error | an entry's name differs from its key |
unknown-parent |
error | a type's parent is not a type |
unknown-key |
error | a type's keys names a predicate that does not exist |
key-outside-domain |
error | a type's key predicate never applies to that type |
unknown-range |
error | a range is neither a type nor a literal type |
unreachable-predicate |
error | nothing in a predicate's domain is a type, so no snippet includes it |
scope-undeclared-qualifier |
error | cardinality_scope names a key that is not a qualifier |
scope-not-identity |
error | cardinality_scope names a reconcilable qualifier |
duplicate-alias |
error | one alias points at two types, or two predicates |
unknown-inverse |
error | inverse_of names a predicate that does not exist |
inverse-not-edge |
error | an end of an inverse, or a symmetric predicate, is a property |
inverse-not-mutual |
error | an inverse does not name its partner back |
inverse-domain-range |
error | an inverse's ends do not swap |
unknown-domain |
warning | part of a domain is not a type |
inheritance-cycle |
warning | types inherit from each other |
unreviewed |
warning | the schema is still marked inferred |
Inverse and symmetric predicates¶
inverse_of names the edge that states a predicate the other way round, and
symmetric: true marks an edge that is its own inverse. The pipeline adds each
fact's partner without a model call
(Concepts,
DECISIONS #28). Declare an inverse on one side; loading fills
in the other. A predicate's range must sit inside its inverse's domain.
geo = Ontology.from_yaml("""
types: {Place: {}, Person: {}}
predicates:
located_in: {domain: [Place], range: Place, inverse_of: contains}
contains: {domain: [Place], range: Place, cardinality: multi}
spouse: {domain: [Person], range: Person, symmetric: true}
""")
assert geo.inverses == {"located_in": "contains", "contains": "located_in", "spouse": "spouse"}
Diff¶
old.diff(new) returns a SchemaChange(kind, path, breaking, detail) for
every difference.
- Breaking: a removed type, predicate or qualifier; a narrowed range or
domain; a changed cardinality; a scope that loses a key; a predicate that
becomes required; changed entity keys; a qualifier whose
identityflips, or that is added as identity-bearing; aninverse_oforsymmetricremoved or changed. - Compatible: a widened range (to an ancestor type, or
integertonumber), a new inverse, and every documentation change.
employs = {"domain": ["Person"], "range": "Company"}
types = {"Person": {}, "Company": {}}
multi = {**employs, "cardinality": "multi"}
old = Ontology.from_dict({"types": types, "predicates": {"employer": multi}})
new = Ontology.from_dict({"types": types, "predicates": {"employer": employs}})
for change in old.diff(new):
print(change)
# breaking changed predicates.employer.cardinality: 'multi' → 'single' (subjects already holding several values now conflict)
Every fact a pipeline writes names the ontology that checked it, by its
fingerprint (odke.ontology). odke ontology diff old.json new.json --store
out follows the changes with how many stored facts each ontology checked,
and the first the old one did: the facts a breaking change may no longer fit
(Which ontology checked a fact).
--tenant acme reads that tenant's facts alone; left out, a Neo4j store is
read whole.
Cardinality and its scope¶
cardinality says how many values a subject may hold; cardinality_scope says
within what. The scope always includes the identity-bearing qualifiers, so with
an identity-bearing tier, a single price means one price per subject per
tier. Declared scope keys must be identity-bearing qualifiers.
Predicate.scope_keys is the resolved tuple, and the
Neo4j cardinality check groups by it.
price = ontology.predicates["price"]
assert (price.cardinality, price.cardinality_scope, price.scope_keys) == ("single", (), ("tier",))
Freezing¶
ontology.freeze(by=...) returns a copy with inferred cleared and
frozen_at and frozen_by set. It raises OntologyFreezeError while
validate() reports an error, and ValueError for an empty by. After that,
diff reports an edit like any other.
frozen = ontology.freeze(by="Ada Lovelace")
assert (frozen.inferred, frozen.frozen_by) == (False, "Ada Lovelace")
ontology.fingerprint is the SHA-256 of the schema's content: its types and
predicates as canonical JSON. name, version and the review fields are not in
it, so a frozen or relabelled copy keeps it, and any change to a type or a
predicate gives a new one. Two ontologies with one fingerprint check every fact
alike. ontology.snippet(name).fingerprint is the same for the schema slice a
model extractor is shown for one type.
assert frozen.fingerprint == ontology.fingerprint and len(ontology.fingerprint) == 64
Drafting one (parked)¶
For a corpus with no schema, odke ontology infer drafts one. Record shapes,
Hearst patterns and co-occurrence propose types and predicates, each with the
spans behind it. Unless --no-llm is given, one call through the infer model
role then names and merges them. The draft is marked inferred=True and
carries its evidence as YAML comments. Until it is frozen, validate() warns
unreviewed and Neo4jSink warns UnreviewedOntologyWarning. Nothing infers
on its own: odke run refuses an inferrer (DECISIONS #8).
Inference is parked: it works, and is not being extended.
odke ontology infer corpus/ --out draft.yaml --no-llm # no model, no key
odke ontology validate draft.yaml # warns: unreviewed
# review and edit draft.yaml
odke ontology freeze draft.yaml --by "Ada Lovelace" # refuses while there is an error
From the shell¶
These commands load non-strictly, so they can show what is wrong. .yaml and
.yml files are read as YAML, anything else as JSON.
odke ontology validate schema.json other.yaml # exit 1 if any file has an error
odke ontology diff old.json new.json --fail-on-breaking
odke ontology types schema.json # each type and how many predicates it can carry
odke ontology snippet schema.json Person # the prompt fragment for one type