Skip to content

Write to a store

A sink has one method, write(kg). Every graph sink writes one shape, from one write plan (openodke.sinks.neo4j.plan()).

The shape

openodke Written as Matched on a rerun by
Entity a node (:Type:Entity {key}) with label, aliases, external_id, resolution_* and its attributes type and key
an edge fact a relationship (s)-[:predicate]->(o) with its provenance its signature
a property fact the same, to a (:Claim {signature, value}), and the value projected as s.predicate its signature
EntityLink a SAME_AS, SIMILAR or DIFFERENT relationship (links) its keys and kind

The examples write this graph:

import tempfile
from pathlib import Path

from openodke import (
    Entity,
    EntityLink,
    Evidence,
    Fact,
    KnowledgeGraph,
    LinkKind,
    Ontology,
    Polarity,
    Span,
)

ontology = Ontology.from_dict(
    {
        "name": "companies",
        "types": {"Person": {}, "Company": {}},
        "predicates": {
            "employer": {"domain": ["Person"], "range": "Company"},
            "hq": {"domain": ["Company"]},
            "sells": {"domain": ["Company"], "cardinality": "multi"},
            "uptime": {
                "domain": ["Company"],
                "range": "number",
                "qualifiers": {"percentile": {"identity": True}},
            },
        },
    }
)
ada = Entity(key="p:ada", type="Person", label="Ada Lovelace")
acme = Entity(key="c:acme", type="Company", label="Acme")
acme_ltd = Entity(key="c:acme-ltd", type="Company", label="Acme Ltd")
report = Evidence(
    doc_id="annual-report",
    uri="https://example.com/ar",
    span=Span(doc_id="annual-report", start=0, end=18, quote="Acme is in Munich."),
)
lost = {"status": "lost", "reason": "Munich is backed by a more trusted source"}
kg = KnowledgeGraph(
    entities=(ada, acme, acme_ltd),
    facts=(
        Fact(subject=ada, predicate="employer", object_entity=acme, evidence=(report,)),
        Fact(subject=acme, predicate="hq", object_value="Munich", support=3, evidence=(report,)),
        Fact(
            subject=acme,
            predicate="hq",
            object_value="Berlin",
            support=4,
            qualifiers={"odke.conflict": lost},
            evidence=(report,),
        ),
        Fact(
            subject=acme,
            predicate="sells",
            object_value="customer data",
            polarity=Polarity.DENIED,
            evidence=(report,),
        ),
        Fact(
            subject=acme,
            predicate="uptime",
            object_value=99.9,
            qualifiers={"percentile": "p95"},
            identity_keys=("percentile",),
            evidence=(report,),
        ),
    ),
    links=(
        EntityLink(
            source_key="c:acme",
            target_key="c:acme-ltd",
            kind=LinkKind.DIFFERENT,
            reason="external_id mismatch: DE-114322 vs GB-889401",
        ),
    ),
)
out = Path(tempfile.mkdtemp())

Projection and reruns

  • Projection. A value is set on its subject only when the fact is asserted, has no identity-bearing qualifier, and did not lose a conflict (odke.conflict status lost, stored as JSON text). A single-valued predicate carries its best-supported claim (highest support, then confidence); a multi-valued one a list, when the sink has the ontology.
  • A rerun into Neo4j or NetworkX updates each node, relationship and claim found by its key: a relationship's properties are replaced (SET r =), node attributes and projected values accumulate (SET n +=), and nothing is deleted; that is the reconciler's job.

Merge with the store

A claim the store holds, stated by another source, gains that source and keeps its own (DECISIONS #35). A sink that can say what it holds is a FactLookup, whose stored(facts) returns the stored fact for each signature; SignatureCorroborator(store=...) merges those in before the scorer and the gate, a statement replacing a derived twin (DECISIONS #28). Neo4jSink.stored reads through each predicate's uniqueness index. A store never bootstrapped, or missing the index of a predicate its ontology declares, is refused with the bootstrap to run (DECISIONS #53): without the index a write would replace the stored support lists. A predicate no ontology declares gets no index, so its facts are not read, with a warning. JsonlSink(directory, merge=True) keeps what the files hold.

Two writers at once into one Neo4j keep each other's sources (DECISIONS #51). Every fact relationship carries odke.version, a token each write renews. A write sends the token stored() read, and the store takes the row only if it still holds that token. A fact another job wrote, or the reconciler retracted from, in between is read again, merged with what this job brought, and scored again in the same write, with no model call. The reconciler locks what it retracts from.

The Validator merges by default with every FactLookup sink (report.restated counts them), as does odke validate with a config's sinks and -o --merge; a dry run reads none. odke run merges when its config names corroborator: signature; with its default pass-through corroborator, a jsonl sink with merge: true replaces a restated fact.

from openodke import Document, Validator
from openodke.sinks import JsonlSink
from openodke.stages import PassThroughGate, PassThroughGrounder

store = JsonlSink(out / "store", merge=True)
validator = Validator(
    ontology, grounder=PassThroughGrounder(), gate=PassThroughGate(), sinks=[store]
)


def employed(doc_id):
    cited = Evidence(doc_id=doc_id, span=Span(doc_id=doc_id, start=0, end=14))
    return Fact(subject=ada, predicate="employer", object_entity=acme, evidence=(cited,))


validator.validate([employed("ar-2025")], [Document(id="ar-2025", text="Ada joined Acme.")])
_, report = validator.validate(
    [employed("ar-2026")], [Document(id="ar-2026", text="Ada is at Acme.")]
)
(held,) = store.stored([employed("any")]).values()
print(report.restated, held.support, [s.source for s in held.supported_by])
# 1 2 ['doc:ar-2025', 'doc:ar-2026']

Reconcile

The reconciler retracts a source (DECISIONS #37):

  • Delete. Each fact a document backed loses it from its evidence and support list, derived facts included.
  • Update. The same, then the new version is validated and merges back.
  • Retire. A fact left with no source keeps retired_at, no evidence and support 0, and is not projected, checked or asserted in RDF until a source states it again. hard_delete=True (--hard-delete) deletes it.

Reconciling twice changes nothing.

Python Command line
a document is gone Reconciler(sinks).delete(doc_ids) odke reconcile --sink <dir or bolt://…> --delete <doc-id>
a document changed Validator(..., sinks=...).validate(facts, [new], update=True) odke validate ... --update

JsonlSink and Neo4jSink are Retractable; neither rescores. An update retracts first, so rerun one that fails before writing. A text that fails on its own after it was retracted is named in the report, <id>: retracted, not rewritten; run it again (report.unrewritten), and odke validate --update exits 1.

from datetime import UTC, datetime

from openodke import Reconciler

report = Reconciler([store]).delete("ar-2025", at=datetime(2026, 10, 9, tzinfo=UTC))
print(report.render())
# odke reconcile
# retracted     ar-2025
# cited         1 fact
# lost support  1 fact, still backed
# retired       0 facts left with no source, kept
(held,) = store.stored([employed("any")]).values()
assert [s.source for s in held.supported_by] == ["doc:ar-2026"]

Reconciler([store]).delete("ar-2026")
(gone,) = store.stored([employed("any")]).values()
assert (gone.support, gone.retired_at is not None) == (0, True)
assert Reconciler([store]).delete("ar-2026").cited == 0  # again: nothing moves

odke reconcile --sink takes a JSONL directory, or a Neo4j URI with --user, --password-env, --database and --ontology.

Tenants

Two tenants who state the same claim own two facts in one store (DECISIONS #44). Give the tenant once: Validator(..., tenant="acme"), tenant: acme in a run config, or --tenant acme on odke run, validate and reconcile; every sink and lookup with scoped(tenant) is scoped: Neo4j, the Cypher file, neo4j-admin CSV, RDF and JSONL.

Its keys are acme/<key>, its signatures are hashed with acme, and its nodes and relationships carry tenant: "acme" (an attribute or qualifier named tenant becomes attribute_tenant or qualifier_tenant). Lookups, merges, the reconciler, check() and read_neo4j(driver, tenant=...) stay in the tenant and give its own keys back; with no tenant they see only what no tenant wrote, and one scoped to a tenant refuses another. A JSONL directory holds one tenant. A tenant is letters, digits, _, . and -, starting with a letter or digit.

from openodke.sinks.neo4j import plan


def rows(tenant, kind):
    return [row for s in plan(kg, tenant=tenant) if s.kind == kind for row in s.rows]


assert {row["key"] for row in rows("acme", "entity")} == {
    "acme/p:ada",
    "acme/c:acme",
    "acme/c:acme-ltd",
}
assert all(row["props"]["tenant"] == "acme" for row in rows("acme", "edge"))
# The same claim for another tenant is another relationship.
assert rows("acme", "edge")[0]["signature"] != rows("beta", "edge")[0]["signature"]

The write report

Each sink reports, per kind, the rows written new, merged into one it held, and skipped (a link with no node to MATCH), and Neo4j's transactions; a sink without a report lists what it was handed. It covers the whole job, and is ValidationReport.writes, stats["writes"] (keyed 0:Neo4jSink), and the run manifest's and job.end's writes:

writes        0:Neo4jSink: entities 3 written; facts 2 written, 1 merged; links none; 1 transaction

Choose a sink

Class odke run name Extra Writes On a second write
openodke.sinks.JsonlSink jsonl — JSON Lines and manifest.json rewrites the files, or merges
openodke.sinks.neo4j.Neo4jSink neo4j neo4j a live Neo4j updates in place
openodke.sinks.bulk.CypherFileSink cypher_file — a .cypher script rewrites the file
openodke.sinks.bulk.Neo4jAdminCsvSink neo4j_admin_csv — CSV for neo4j-admin database import rewrites the files
openodke.sinks.rdf.RdfSink rdf rdf Turtle, N-Triples or JSON-LD rewrites the file
openodke.sinks.networkx.NetworkXSink networkx networkx a networkx.MultiDiGraph adds and updates, never removes

A missing extra fails naming its install command. Options in odke run: Sinks.

Neo4j

Neo4j 5.7 or later, Community Edition included: pip install "openodke[neo4j]".

Neo4jSink(uri, auth, *, database=None, batch_size=500, driver=None, ontology=None, tenant=None) writes batched UNWIND … MERGE in transactions of at most batch_size rows. Creating it does not connect. sink.bootstrap(ontology) applies the constraints and indexes (IF NOT EXISTS) before the first write; dry_run=True returns them.

from openodke.sinks.neo4j import Neo4jSink

with Neo4jSink("bolt://localhost:7687", auth=("neo4j", "change-me"), ontology=ontology) as sink:
    ddl = sink.bootstrap(ontology, dry_run=True)  # the DDL, for a DBA to apply
    planned = sink.statements(kg)  # what write() would run, in order

projected = [s.rows for s in planned if s.kind == "projection"]
assert projected == [[{"subject_key": "c:acme", "value": "Munich"}]]
berlin = next(r for s in planned if s.kind == "claim" for r in s.rows if r["value"] == "Berlin")
assert '"status": "lost"' in berlin["props"]["odke.conflict"]  # JSON text
print(ddl[2])
# CREATE CONSTRAINT odke_key_Company IF NOT EXISTS FOR (n:`Company`) REQUIRE n.key IS UNIQUE
import os

uri, auth = os.environ["NEO4J_URI"], (os.environ["NEO4J_USER"], os.environ["NEO4J_PASSWORD"])
with Neo4jSink(uri, auth, database="neo4j") as sink:
    sink.bootstrap(ontology)
    sink.write(kg)
    print(sink.check(ontology))  # {'hq': [{'subject': 'c:acme', 'objects': [...], ...}]}

In a Pipeline, pass constrainer=Neo4jConstrainer() beside the sink; it raises no DoubleStageWarning.

What Neo4j enforces, and what it cannot

Rule How
one node per (type, key) a uniqueness constraint per entity type
one relationship per (predicate, signature), one :Claim per signature uniqueness constraints
one value for a single predicate not enforceable: check()
existence, property-type, node-key Enterprise only: not emitted
domain and range not enforced: VerdictGate(schema=True)

bootstrap() also creates indexes: per type on external_id, and full-text on label and aliases; on key for :Entity; per predicate full-text on evidence_doc_ids, and on odke.ontology. An undeclared type or predicate gets none. The store lookup and the reconciler read through them.

check()

sink.check(ontology) returns {predicate: [violators]} for each single predicate: asserted, live relationships with no valid_to, grouped by scope_keys.

from openodke.sinks.neo4j import Neo4jConstrainer

(check,) = [c for c in Neo4jConstrainer().checks(ontology) if "odke:check hq" in c]
print(check)
# // odke:check hq
# MATCH (s)-[r:`hq`]->(o)
# WHERE r.polarity = 'asserted' AND r.valid_to IS NULL AND r.retired_at IS NULL
# WITH s, collect(DISTINCT coalesce(o.key, o.value)) AS objects
# WHERE size(objects) > 1
# RETURN labels(s) AS labels, s.key AS subject, objects

Provenance on every fact

Property Holds
fact_id, signature, polarity, identity_keys the fact's own fields; signature is a SHA-256 of Fact.signature
extractor, verdict, confidence, support what the stages decided
support_sources, support_tiers, support_retrieved_at, support_doc_ids, support_doc_sources the support list as parallel lists; support_from(props) reads it back (DECISIONS #33)
valid_from, valid_to, retrieved_at, retired_at, extracted_at the valid clock; the newest evidence; when retired; KnowledgeGraph.created_at
evidence_doc_ids, evidence_uris, evidence_starts, evidence_ends, evidence_tiers, evidence_retrieved_at parallel lists; a missing uri is "", a missing span -1
evidence_span_origins cited, located or context (DECISIONS #25)
each qualifier under its own name, or qualifier_<name> if a property above has it; a map or mixed list as JSON text
// Why is this edge here?
MATCH (:Person {key: $person})-[r:employer]->(c:Company)
RETURN c.key, r.evidence_doc_ids, r.evidence_starts, r.evidence_ends, r.verdict, r.support;

// Every fact from one source, edges and claims alike.
MATCH (s:Entity)-[r]->(o) WHERE $doc IN r.evidence_doc_ids AND r.signature IS NOT NULL
RETURN s.key, type(r) AS predicate, coalesce(o.key, o.value) AS object, r.polarity;

// Facts the corroborator voted down. odke.conflict is JSON text.
MATCH (s:Entity)-[r]->(o) WHERE r.`odke.conflict` CONTAINS '"status": "lost"'
RETURN s.key, type(r) AS predicate, coalesce(o.key, o.value) AS object, r.`odke.conflict`;

Which ontology checked a fact

Every fact the gate passes carries the qualifier odke.ontology, the fingerprint of the last ontology that checked it (DECISIONS #42); a model's fact also carries odke.schema_slice, its snippet's. Neither is in the signature. Find an old ontology's facts through the index:

// The employer facts one ontology checked, by its fingerprint.
MATCH (s)-[r:employer]->(o) USING INDEX r:employer(`odke.ontology`)
WHERE r.`odke.ontology` = $fingerprint
RETURN s.key, coalesce(o.key, o.value) AS object, r.fact_id;

Neo4jSink.checked_under(ontology) runs that per predicate in one transaction, for the sink's tenant alone when it has one. checked_under(facts, ontology) and checked_by(fact) in openodke.corroborate do it for facts in hand. Each takes an ontology or a fingerprint.

from openodke import Document, Entity, Evidence, Fact, Ontology, Pipeline, Span
from openodke.corroborate import ONTOLOGY, checked_by, checked_under


class OneClaim:
    def extract(self, chunk, ontology):
        acme = Entity(key="c:acme", type="Company")
        cited = Evidence(doc_id="d1", span=Span(doc_id="d1", start=0, end=21))
        return [Fact(subject=acme, predicate="hq", object_value="London", evidence=(cited,))]


companies = Ontology.from_dict(
    {"types": {"Company": {}}, "predicates": {"hq": {"domain": ["Company"]}}}
)
checked = Pipeline(companies, OneClaim()).run([Document(id="d1", text="Acme is in London now.")])
fact = checked.facts[0]
assert checked_by(fact) == fact.qualifiers[ONTOLOGY] == companies.fingerprint
assert checked_under(checked.facts, companies) == [fact]

odke ontology diff --store counts them for a schema change.

Cypher file

CypherFileSink(path, *, ontology=None, batch_size=500, tenant=None) writes Neo4jSink's statements as a script, opening with the bootstrap DDL when given an ontology. A tenant keys the script as it keys the driver's writes (Tenants); odke run and the Validator pass theirs. Replay it with cypher-shell -a <uri> -u <user> -f graph.cypher; replaying twice changes nothing.

from openodke.sinks.bulk import CypherFileSink

CypherFileSink(out / "graph.cypher", ontology=ontology).write(kg)

neo4j-admin CSV

Neo4jAdminCsvSink(directory, *, ontology=None, delimiter=",", array_delimiter=";", tenant=None) writes one CSV file per node or relationship group, with typed headers, and an import.args file. An import builds a new database. A tenant keys the IDs and signatures as the driver keys them.

from openodke.sinks.bulk import Neo4jAdminCsvSink

Neo4jAdminCsvSink(out / "import", ontology=ontology).write(kg)
assert (out / "import" / "import.args").is_file()
# then: cd import && neo4j-admin database import full @import.args neo4j

RDF

RdfSink(path, *, format=None, base="https://example.org/odke/", schema=None, ontology=None, tenant=None) writes each fact as a reified rdf:Statement with its provenance, evidence (odke:evidence) and sources (odke:supported_by), in odke: (https://openodke.dev/vocab#). The plain triple <s> <predicate> <o> follows the projection rule. An ontology adds the schema as OWL and types values by range. The suffix picks the format: .ttl, .nt, .jsonld or .json. A tenant goes into every entity and fact IRI, and odke:tenant names it.

from openodke.sinks.rdf import RdfSink

graph = RdfSink(out / "graph.ttl", ontology=ontology).graph(kg)  # what write() serialises
ASK = "PREFIX ont: <https://example.org/odke/schema/> ASK { ?company ont:%s ?value }"
assert graph.query(ASK % "hq").askAnswer
assert not graph.query(ASK % "sells").askAnswer  # a denial
assert not graph.query(ASK % "uptime").askAnswer  # a value without its percentile

NetworkX

NetworkXSink(graph=None, *, ontology=None) fills a networkx.MultiDiGraph: a node per entity key, a node claim:<signature> per claim, and edges whose kind is fact or link.

from openodke.sinks.networkx import NetworkXSink

inspector = NetworkXSink(ontology=ontology)
inspector.write(kg)
inspector.write(kg)  # a rerun adds nothing
assert (inspector.graph.number_of_nodes(), inspector.graph.number_of_edges()) == (7, 6)
assert inspector.graph.nodes["c:acme"]["hq"] == "Munich"

JSONL

JsonlSink(directory, *, merge=False, tenant=None) writes JSON Lines, and the stage counts and run manifest to manifest.json. facts.jsonl is a predictions file for odke eval. append(kg) adds a graph without reading the files (or merges, with merge=True): a streamed run appends each micro-batch, and a reader keeps a fact's last line. A merging store reads and rewrites itself whole on every micro-batch, so a streamed run into one takes time that grows with the square of the store (8,000 rows: 51 s); it warns once, and Neo4j is the store for that load. Every file is written whole (to a temporary file, then renamed), so two writers into one directory never crash on a torn file; a directory still has one writer at a time, and of two merging writes the later one's files stay. RDF, cypher_file and neo4j_admin_csv rewrite the whole graph (streams = False), so a streamed run refuses them.

Your own sink

Anything with write(kg) is a sink (DECISIONS #5). plan(kg, ontology=...) and provenance_of(fact, extracted_at) in openodke.sinks.neo4j give the shape above. A store that does a stage itself declares profile = PlatformProfile(...) (Concepts). openodke.eval.assert_idempotent(sink, kg, counts) writes twice and checks that nothing moved.