Skip to content

Decisions

The calls that shaped the design, and what each one cost. Written down because the reasoning is the part that gets lost, and a decision without its reason gets reversed by the next person who finds it inconvenient.

1. The base install talks to nothing

pip install openodke pulls pydantic and typer. No model provider, no database driver, no HTTP client. Compiling and inspecting an ontology is a genuinely useful thing to do offline, and it should not require credentials to exist.

Cost: the README has to explain extras, and verify.sh step 8 exists solely to keep this honest.

2. One Fact class for edges and properties

object_entity and object_value are mutually exclusive fields on one class rather than two classes. Both kinds need identical provenance, grounding and corroboration; when they were split, every stage had two nearly identical code paths and they drifted.

Cost: an invalid state is representable — both fields set. Validation catches it; the alternative was worse.

3. Spans are character offsets, not quoted strings

A model can produce a quote that reads perfectly and appears nowhere in the source. An offset either resolves to the claimed text or it does not, and checking costs nothing. Span.is_faithful() runs before the grounder is asked, so the cheapest rejection happens first.

Cost: chunking has to preserve offsets back to the original document, which is the hardest part of M2. It is worth it — without this, provenance is decorative.

4. Everything is frozen

Facts pass through four stages and get merged across sources. A stage that edited one in place would make its own provenance wrong. Stages return new objects.

Cost: more allocation. Irrelevant next to a model call.

5. Stages are Protocols, not base classes

Extractor, Grounder, Corroborator, Sink are typing.Protocol. Anyone can supply their own by writing one method — no import of ours, no registration, no inheritance. This is what makes "or any graph DB" true rather than aspirational.

Cost: no shared implementation to inherit. There was not much to share.

6. Snippets are data, rendered two ways

OntologySnippet holds structure; .render() produces prose and .json_schema() produces a schema. One object, so the prompt and the structured -output contract cannot describe different things — which they will, eventually, if they are built separately.

7. Two model adapters, not one, and not a hundred

litellm covers ~100 providers and is the obvious single answer — but making it a hard dependency means pip install openodke drags in a large package before the user has decided to call a model at all, and it makes the most common serious setup (a local Ollama) require it too.

So: an OpenAI-compatible client written against the standard library handles everything that speaks that shape — Ollama, vLLM, LM Studio, llama.cpp, OpenRouter, Groq, Together, DeepSeek, proxies, gateways — and litellm, behind the [llm] extra, handles the rest. resolve() picks between them by a rule short enough to read.

Cost: two adapters to keep behaviourally identical. test_llm.py asserts they normalise to the same Completion, including that unknown cost is None rather than 0.0 in both.

7a. Model roles are part of the configuration

ModelRoles names three jobs — extract, ground, infer — and defaults grounding to a smaller model than extraction. The paper's precision comes from a second verification pass; if that pass costs the same per call as extraction, people turn it off, and then the architecture does not work.

Defaults name Claude models because something must be the default. Nothing in the library depends on them, and ModelRoles.single("ollama/…") is a first-class configuration rather than a degraded one.

7b. A registry, so an internal gateway is not a fork

Many teams can only call models through their own audited proxy. register("provider", factory) routes every matching model string through a caller-supplied client. Without it, those teams would vendor the library.

8. Ontology inference is a bootstrap, not a mode

Ontology.infer() returns an ontology marked inferred=True for the caller to review, edit and freeze. It is not wired to run implicitly on every call.

A schema that silently re-infers between runs produces a graph whose edge labels change underneath existing queries. The one-time cost of review buys a graph that stays queryable.

9. The Initiator and Retriever are optional

The paper's first two stages watch Wikipedia for edits and fetch evidence. An SDK is normally handed its documents. Both are declared as protocols so the refresh loop can be built without forking, and neither is required to run a pipeline.

10. Trust tiers are an enum with weights, not a free float

Callers reason about "curated vs. scraped", not about 0.8. Four named tiers with fixed weights make conflict resolution explainable, which matters the first time someone asks why the graph picked one of two contradictory answers.

11. Fact.signature excludes reconcilable qualifiers

"CEO since 2019" and "CEO 2019–2024" are one claim told two ways. If qualifiers were part of identity, both would land in the graph as separate edges, which is exactly the failure corroboration exists to prevent.

Amended in M0 (#48): this holds for reconcilable qualifiers, which is what every qualifier is unless the ontology says otherwise. A key the ontology declares identity: true is a different matter — see #15.

12. Verification is one script

scripts/verify.sh is what CI runs and what a contributor runs. There is no second list of steps to drift out of sync with the first.

13. The board is linked to the repo, not auto-populated

Projects v2 boards are owned by a user or an org — createProjectV2 takes an ownerId, and Repository is not a valid owner. Repo-owned boards were Projects (classic), retired in 2024. So the board is account-owned and linked to the repo, which is why it appears under the Projects tab.

The consequence is that GITHUB_TOKEN cannot write to it, and auto-adding new issues would need a PAT stored as a repo secret. For a project this size that trades a rotating credential for a keystroke, so there is no add-to-project workflow. New issues go on the board with:

gh project item-add 6 --owner deepskandpal --url <issue-url>

14. Polarity is a field, and it is part of the signature

Fact.polarity is asserted / denied / partial. Before it existed the only place a denial could go was qualifiers, which #11 keeps out of signature — so "X sells customer data" and "X does not sell customer data" shared a signature, the corroborator merged them, and each raised the other's support. A denial and its own contradiction strengthened each other.

It joins signature because a denial is not the same claim as an assertion; it is the opposite one. Hand annotation of a real corpus found 6% of facts negative or partial, which is too many to lose and too many to merge wrongly.

Cost: one more field on a frozen model, and a re-partition of any graph built before it — which is why it lands in M0, before anything is serialised.

15. The ontology says which qualifiers bear identity; the fact carries the answer

11 is right about start_time and wrong about percentile. "Uptime 99.9% at

p50" and "uptime 99.9% at p95" are two measurements; with qualifiers excluded from signature they merged into one claim with support = 2, and where the values differed a single-valued predicate saw a contradiction that was not one. Two in five facts in a real corpus carried a qualifier of this kind.

So Predicate.qualifiers maps each key to a Qualifier(identity=...), defaulting to False — #11 stays the default and nothing in an existing ontology changes meaning. The extractor stamps Ontology.identity_keys(pred) onto Fact.identity_keys, and signature includes those keys, sorted.

The fact carries the key names rather than a reference to the ontology because signature has to stay a pure property of the fact: a serialised fact must mean the same thing after the schema it came from has been edited, and the corroborator must never need the schema in hand to merge two facts.

Cost: an extractor that forgets to stamp identity_keys silently gets #11's old behaviour. That is the safe direction to fail in.

EntityLink(source_key, target_key, kind, score, evidence, reason) with kind one of SAME_AS, SIMILAR, DIFFERENT. neo4j-graphrag's three resolvers replace the nodes they match, and once merged you cannot lower the threshold and re-run to see what moved, because there is nothing left to move. A link is reversible; a merge is not.

DIFFERENT is the kind nobody else records. It is where the disagreement rule lives — a strong identifier that disagrees kills a match however similar the names — and reason names the identifier that disagreed, so the rejection is a query rather than a mystery. A string, not a second structure.

Links ride on the output object as KnowledgeGraph.links, default empty. One output object is what "any sink" means; an empty tuple costs nothing, and a sink with no use for links ignores them.

Cost: the store holds duplicates until something acts on the links. That is the point — acting on them is a choice a caller can revisit.

17. Two clocks: valid time on the fact, transaction time on the evidence

Fact.valid_from / valid_to say when the claim was true in the world. Evidence.retrieved_at and Document.retrieved_at say when we came to believe it. They were one clock before, and one clock cannot tell a CEO who changed from two sources that disagree — one is a fact that expired correctly, the other is a conflict to resolve, and the corroborator has to do opposite things with them.

Neither clock is in signature. "CEO 2019–2024" and "CEO since 2019" are one claim with two views of its interval, which is the reconciliation #11 exists for. Graphiti's bi-temporal model invalidates edges rather than deleting them; that is the same instinct, and the shape here is chosen so it can be borrowed when the staleness queue is built.

Cost: two nullable fields most extractors will leave empty. A migration if added later, which is why they are here now.

18. An entity records how its key was decided

Entity.resolution is Resolution(method, score, linker), with method one of caller, external_id, linker. The paper's two identity paths — a global identifier links directly, otherwise a linker decides — become a field, plus the third case an SDK meets and the paper does not: the caller already knew.

Without it a wrong link is invisible. With it, it is a query — every entity whose identity came from an embedding match below 0.9 — and a DIFFERENT link (#16) is auditable rather than merely recorded. linker also names a platform resolver when the pass was delegated (#21), so a merge the store made can be read back and scored the same way as one made here.

Cost: one nullable field. Left None by anything that did not resolve, which is itself the answer to "who decided this?".

19. The chunk is the unit of the pipeline, and the router sees chunks

Router.route takes a Chunk, not a Document and not a union of the two. A document is a chunk source; document-level routing is a chunker configured not to split, which is exactly what the default chunker does. RouteVerdict carries scope: chunk | document so a router that recognises a marketing page from its first chunk can skip the rest of that document — the one real reason to route at document level, without a second method.

This fixes the unit of every metric: one row is one chunk, and the chunker's configuration is what defines it. The chunker's user-facing size is a word cap and it never splits mid-sentence, because a router asked "fact or narrative?" about half a sentence is asked nothing.

The package ships no taxonomy. RouteVerdict.label is a free string; one corpus's fact / policy / narrative split is an argument to a router, not an enum in this code.

Cost: a Chunk carries doc_id, offsets, text and an index, and nothing else. An extractor that needs the document's modality or tier looks it up.

20. Thirteen Protocols, each with a pass-through default

The generic pipeline is thirteen stages — load, chunk, route, extract, ground, normalise, resolve, corroborate, score, validate, sink, constrain, infer — and every one is a Protocol in openodke.stages with a concrete default that is the identity function, or the nearest thing to one. A caller who wants only extract-and-sink gets no-ops for the other eleven and never notices them.

This is what makes the package a superset rather than a product. Every corpus that has been looked at needs a different subset of the thirteen, and nothing domain-specific — labels, ontologies, tenancy keys, qualifier semantics — enters the code. It all arrives as data through one of these seams.

The extractor is the one stage with no identity function, so it has no default. Grounder stamps a verdict rather than dropping the fact, so an ablation can count what would have gone; the Validator is the gate.

Cost: the four original Protocols changed shape — per chunk and per fact rather than per batch — before anything implemented them. Later would have been a migration for every caller.

21. A stage the platform also does is warned about, never forbidden

The package sits on top of any platform, and some platforms already do a stage: neo4j-graphrag resolves after the write, GraphPruner prunes, an RDF store refuses what breaks SHACL. Doing those twice is waste at best and a second opinion nobody asked for at worst — but forbidding it would be wrong, because the two passes are not the same pass. openodke's exact match on strong identifiers before the write is free and never wrong; the platform's fuzzy pass on names after the write is neither. A caller may want both.

So the mechanism is small. A sink may declare a PlatformProfile saying what its store covers (resolves, constrains, prunes). Delegated(to=...) satisfies every stage Protocol as a pass-through and stamps to wherever the model has a provenance slot — Entity.resolution.linker for a resolver, the verdict's reason for a router or validator — so the platform's work can be read back and scored like ours. And when a real stage is configured on both sides, Pipeline emits one DoubleStageWarning at construction and runs what it was given.

The documentation says plainly that replace loses the evidence a pre-write link would have kept. That is a fact about the platform, not a reason to refuse it.

Cost: a user who ignores the warning gets two resolutions. The evaluator, not the pipeline, is where that shows up as a number.

22. The package is openodke; the tool and its data keep odke

The distribution on PyPI and the import package are openodke. Anyone who has read Apple's ODKE+ paper and searches for it still finds the package, and the open prefix says what it is: an independent, open implementation of a published architecture, not Apple's system. That is the pattern OpenCLIP (OpenAI's CLIP), OpenFlamingo and OpenLLaMA already set, and readers know it. A bare odke on PyPI would read as the paper authors' own release, which is the affiliation confusion PEP 541 lets a name be reassigned over; a name that states its independence avoids that before the first upload rather than after.

The command stays odke — short, and what a person types — and openodke is installed beside it as the same app. The data keeps odke too: the reserved qualifier and attribute keys (odke.source_form, odke.name_key, odke.conflict, odke.score), the resolver's odke.native linker, the RDF odke: prefix and its vocabulary IRI, the Neo4j odke_* index and constraint names and the // odke:check marker. Those are a format, not a Python name: they are documented, and they are what graphs written today contain. Renaming them would be a data migration nobody asked for, bought only for symmetry. The repository and documentation URLs stay where they are until the repository itself is renamed, if it ever is.

The rename came before any release, when nobody depends on import odke, so there is no shim: import odke fails.

Cost: two names to explain — pip install openodke, then odke run. The README and the installation page say it once each.