Christopher Nolan? Crime? Not really... But this is what my first tries for a knowledge graph did.
It wasn't hallucinating. There was a real directed_by edge leaving a node called "Crime," and a plain traversal did exactly what traversals do: it followed the edge and handed back a confident, wrong answer. Every node existed. Every edge existed. The graph was structurally perfect and semantically nonsense.
Here's the thing that fixed it, and the surprise that came with it.
The ontology some rare teams have
You probably have an ontology somewhere. It's usually a picture: a boxes-and-arrowsdiagram, a wiki page, a PDF nobody opens. Mine is a YAML file the code actually loads:
namespace: movie
relationships:
directed_by:
domain: Movie
range: Person
has_genre:
domain: Movie
range: Genre
domain = the type an edge is allowed to leave. range = the type it's allowed to reach. directed_by goes Movie -> Person. That's the whole declaration. Everything below runs off it.
Job one: lint the walk, not the graph
The Nolan bug is a domain violation: directed_by left a Genre node instead of a Movie. You can't catch it by looking at the edge, because the edge is genuinely there. So you check each hop against the declaration before you follow it:
from open_kgo.feature_groups.kg.ontology.registry import OntologyRegistry
OntologyRegistry.load_file("metaqa_ontology.yaml")
def traverse(g, start, relationship, namespace):
entity_type = g.nodes[start].get("type", "Unknown")
if not OntologyRegistry.is_valid_edge(namespace, entity_type, relationship):
raise ValueError(
f"Ontology violation: '{relationship}' is not valid from "
f"entity type '{entity_type}' in namespace '{namespace}'."
)
expected_range = OntologyRegistry.get_range_type(namespace, relationship)
results = []
for _, target, data in g.out_edges(start, data=True):
if data.get("relation") != relationship:
continue
target_type = g.nodes[target].get("type", "Unknown")
if expected_range is not None and target_type != expected_range:
raise ValueError(
f"Range violation: '{relationship}' expects range "
f"'{expected_range}' but reached '{target}' of type '{target_type}'."
)
results.append(target)
return sorted(results)
Now the bad hop fails loudly instead of returning [] in silence:
Ontology violation: 'directed_by' is not valid from entity type 'Genre' in namespace 'movie'.
Two failure shapes get caught. That one is a domain violation (left the wrong type). The other is a range violation (landed on the wrong type), which name collisions love to cause: "Romance" is both a film and a genre, so a has_genre edge can end up pointing at a node typed Movie. The range check stops it:
Range violation: 'has_genre' expects range 'Genre' but reached 'Romance' of type 'Movie'.
This is not SHACL or a SPARQL constraint. Those scan the whole graph, once, after > the fact. This validates the walk, at the hop, as it runs. By the time your agent is > three hops deep, "the query came back empty, something broke" is useless. "Hop 2 is > invalid,
Genrehas no outgoingdirected_by" is a bug you fix before lunch.
The repo ships an eval to prove the split. It builds a batch of traversals that are invalid by construction (every Genre node attempting directed_by, plus Person nodes attempting starred_actors) - 55 cases on the committed sample graph. Plain traversal: silent empty list on 100% of them. Ontology-guided: named error on 100% of them. It's a notebook you can re-run offline in a couple of minutes.
Job two: the same YAML ranks results
Here's the surprise. Ask a graph "which Sci-Fi films did Nolan direct" and you normally get a flat yes/no list. No ranking, and a wrong match can sneak in. I wanted a continuous, explainable score, and it turned out the ontology already had what I needed.
Model the query as a DC circuit. Clip a battery's + terminal to "Nolan," the - terminal to "Sci-Fi," and let current flow. Relationships are wires; the ontology weights are how well each wire conducts. A movie's relevance is the current through it.
from open_kgo.feature_groups.kg.ontology.semantic_field import SemanticField
scores = SemanticField.compute_and(
"movie",
edges, # (source_id, relation, target_id) triples
source={"Nolan": 1.0}, # + terminal
sink={"Sci-Fi": 0.0}, # ground
)
# {"Interstellar": 0.394, "Inception": 0.312, "The Dark Knight": 0.0, ...}
On MetaQA (43k nodes):
| Film | Score |
|---|---|
| Interstellar | 0.394 |
| Inception | 0.312 |
| The Dark Knight | 0.0 |
The Dark Knight scoring a clean 0.0 is my favorite part, and nobody wrote a filter for it. It's an Action film, so from "Nolan" it's a dead end: current reaches it via directed_by, but there's no onward path to "Sci-Fi." With nowhere to flow, that branch floats to the source voltage, the potential difference across it is zero, so the current is zero. The "Nolan AND Sci-Fi" is enforced by the wiring, not by a line of code. Interstellar connects to both terminals, sits at V=0.5625, and carries 0.9 * (1.0 - 0.5625) = 0.394.
Bonus: partial matches survive. A film that's only loosely Sci-Fi carries a little current and gets a small score, instead of being guillotined by a hard cutoff. You get a ranking, not a yes/no.
Being honest about the math
The graph crowd will ask, so: this math is not new. Fixing two anchor voltages and solving for the rest is the harmonic / Dirichlet problem on a graph (Doyle & Snell's Random Walks and Electric Networks; it's also label propagation, Zhu, Ghahramani & Lafferty 2003). Scoring a node by the current it carries between two terminals is the single-query case of current-flow betweenness (Newman; Brandes & Fleischer). None of that is my invention. What's new here is the packaging: ontology weights as
conductances, a query as a source/sink pair, and a deterministic, training-free score that drops dead ends for free. The classical foundations are exactly why it behaves predictably.
Why one declaration does both
I didn't plan the symmetry. The linter needs to know what each relationship connects (domain/range). The circuit needs to know how strongly each relationship conducts (the weight). Both live on the same relationship in the same YAML. Once the ontology is a runtime object instead of documentation, "is this edge legal?" and "how much does this edge carry?" are just two questions you ask the same file. And because it's one source of truth, validity and relevance can't drift apart.
That's the pitch. An ontology-as-PDF answers neither at runtime. An ontology-as-code answers both.
Try it
Everything above runs offline against committed fixtures, no Docker, no API keys:
- Linter demo:
demo/demo_kg_ontology.py - Circuit demo:
demo/demo_semantic_field.py
open-kgo puts one API over nine families of knowledge-graph backend (RDF/SPARQL, property graphs, in-memory, agent memory, and more), so the ontology travels with the connector and switching backend is a config change. It's Apache-2.0 and early, and I'd genuinely like to be told where the model breaks.
If your ontology is a PDF today, this is what it looks like as code.
Top comments (0)