Skip to content

The Validator

openodke is the verification layer between any extractor and the graph store (DECISIONS #24). The Validator is that layer as one class, openodke.Validator, and one command, odke validate. Give it another extractor's triples and their texts: it grounds, normalises, resolves, corroborates, gates and writes them, and reports what each step did. odke ground is the grounding step alone.

One command

On the five triples in examples/triples/, with recorded answers and no key:

odke validate --facts examples/triples/triples.jsonl --texts examples/triples/texts \
  --ontology examples/triples/ontology.json --config examples/triples/odke.yaml -o out
odke validate
in            5 facts from 1 text
grounded      supported 3, contradicted 0, not_found 2, unchecked 0
free checks   1 refused: 1 not in the text
refused       0 by the gate
merged        0 into a fact with the same signature
linked        0 entity links
derived       0 inverse and symmetric partners
out           5 facts (4 edges, 1 property), 5 entities
cost          4 model calls, 657 tokens, USD unknown (ground.span@1)
coverage      0 of 1 sentences naming two known entities uncovered, 0 entities in no fact, relations offered: unknown, 0 unused
calibration   ground.span@1 on anthropic/claude-haiku-4-5-20251001 (inline): κ 0.12 [-0.09, 0.38] (n 104), below its bar (κ ≥ 0.6); fail-recall 0.20, false refusals 0.09, planted caught 0.80 (label set G gate, n 104, 2026-10-25); refusals marked for review, not dropped (its card is below the bar, DECISIONS #54)
wrote         jsonl → out: entities.jsonl 5, facts.jsonl 5, links.jsonl 0, manifest.json, refused.jsonl 0
writes        0:JsonlSink: entities 5 written; facts 5 written; links none
manifest      out/manifest.json

The two not_found facts are written: the default gate refuses only what the text contradicts and what the ontology has no room for. To keep them out too, give the gate refuse_not_found (the gate). Beside the graph, -o writes refused.jsonl, each refused fact with the gate's reason, which odke eval refusals samples for a person to judge.

The calibration line is the grounder's calibration card, or UNCALIBRATED where there is none, and what its refusals did in this job. The default grounder's card is below its bar, so a fact it contradicts is written marked for review (qualifiers["odke.review"], counted under marked) instead of refused; on_refusal="drop" refuses it (DECISIONS #54).

Flag
--facts, --texts, --adapter, --ontology The facts and their texts, as odke ground reads them
--config A run config whose extractor is triples: its inputs, ontology, models, stages and sinks, a stage it leaves out taking the Validator's default. With --facts, only its models block is read
-o Also write the graph here as JSONL
--merge Merge into the JSONL already at -o
--update The texts are new versions of ones the store cites: retract each first. Needs a sink that can retract; implies --merge
--locate The span locator
--dry-run The free checks, the locator and every deterministic stage: no model, no write
--model, --model-provider, --cache, --budget-usd, --budget-calls, --log-format, --log-text As odke run takes them
--batch-size Stream this many rows at a time
--tenant Key the store by this tenant
--text-property, --user, --password-env, --database Neo4j, as odke ground takes them

Exit 2 is input or a config that cannot be read, exit 1 a failed run, exit 3 a run its budget stopped, which still writes what it kept.

In Python

from openodke import Document, Ontology, Validator
from openodke.llm import RecordedClient

places = Ontology.from_dict(
    {
        "types": {"Region": {}, "Country": {}, "Company": {}},
        "predicates": {
            "located_in": {"domain": ["Region"], "range": "Country", "inverse_of": "contains"},
            "contains": {"domain": ["Country"], "range": "Region"},
            "founded": {"domain": ["Company"], "range": "integer"},
        },
    }
)
a = Document(id="a", text="Brittany is in France. Halden Robotics was founded in 2014.")
b = Document(id="b", text="Brittany lies in France. Halden Robotic opened in Lyon.")
halden = {"doc": "a", "subject": "Halden Robotics", "predicate": "founded"}
rows = [
    {"doc": "a", "subject": "Brittany", "predicate": "located_in", "object": "France"},
    {"doc": "b", "subject": "Brittany", "predicate": "located_in", "object": "France"},
    {**halden, "object": 2012, "quote": "Halden Robotics was founded in 2014."},
    {**halden, "object": 2014, "quote": "Halden Robotics was founded in 2014."},
    {"doc": "b", "subject": "Halden Robotic", "subject_type": "Company",
     "predicate": "opened_in", "object": "Lyon"},
]  # fmt: skip
# Recorded answers, so this runs with no key. Drop `client=` to call a model.
client = RecordedClient(
    [
        {"match": "— 2012.", "response": {"verdict": "contradicted"}},
        {"match": "Claim:", "response": {"verdict": "supported"}},
    ]
)
kg, report = Validator(places, client=client).validate(rows, [a, b])
print(report.facts_in, report.refused, report.merged, report.linked, report.derived)
# 5 2 2 1 2
print(report.refused_by, report.calls)
# {'contradicted': 1, 'predicate': 1} 4

The two refusals are the contradicted 2012, and opened_in, which is not in the ontology and so cost no call. Brittany's located_in, stated in both texts, merged into one fact with support 2, and its inverse partner contains was derived from each and merged too. Halden Robotic became a SIMILAR link to Halden Robotics.

validate(facts, documents=(), *, dry_run=False, extractor="triples", confidence=0.5, update=False, batch_size=None) takes any adapter's (rows, documents) (Inputs), a triples file and its texts, or Facts and their texts. Rows become facts named extractor, with confidence as their prior. It returns the KnowledgeGraph and a ValidationReport, writing the graph to every sink in sinks=. A Validator keeps its stages between calls, and each report counts its own call. batch_size=N streams the rows; the graph returned then holds the stats and no facts.

import tempfile
from pathlib import Path

from openodke.sinks import JsonlSink

store = JsonlSink(Path(tempfile.mkdtemp()), merge=True)
_, streamed = Validator(places, client=client, sinks=[store]).validate(rows, [a, b], batch_size=1)
# a, b, a a, b: each micro-batch ends where its rows' text changes.
print(streamed.batches, streamed.restated)
# 4 2

The stages

Validator(ontology=None, *, ...) builds each stage left out as odke run would, with that name's defaults. Passing a stage replaces its default, and a pass-through from openodke.stages turns it off.

Argument Default
grounder LLMGrounder(roles, client=client, locate=locate). Any grounder runs behind the free checks (CheckedGrounder)
roles, client, locate ModelRoles(), resolved from the model, False
normalizer ValueNormalizer(ontology)
resolver NativeResolver(ontology=ontology), given lookup, judge, normalize_batch and embed; with a resolver of your own, give it those instead
lookup none; a StoreLookup resolves against the store
judge none; a PairJudge is the pair judge, never asked in a dry run
normalize_batch, embed False, none: normalise the batch
corroborator SignatureCorroborator(ontology, store=...), given every sink that is a FactLookup, so a write merges with the store
scorer EvidenceScorer()
gate VerdictGate(schema=True): refuses what the text contradicts and what the free checks on the ontology failed, since they leave the verdict alone
inverses on when the ontology declares a pair (DECISIONS #28)
coverage True: what extraction left behind
sinks none
manifest none; a path to write the run manifest to as well
tenant none; scopes every sink and lookup to a tenant

Without an ontology, the checks on the relation and the types have nothing to check.

The report

ValidationReport covers one validate() call. In + derived − refused − merged = out.

Field Holds
documents, facts_in, unmatched texts, facts in, and rows whose text was not given
verdicts, checked every fact in by its verdict; what the free checks refused
refused, refused_by the gate's refusals, by reason
merged, restated facts merged by signature; of those, merged with the store
linked, links, derived resolver links, by kind; inverse partners added
facts_out, edges, properties, entities the graph
calls, cached, tokens, cost_usd, prompts the grounder's and judge's calls, cost_usd None until priced, and the prompts sent
coverage as KnowledgeGraph.stats["coverage"]
store, judge, batch with a lookup, a judge or batch normalisation: their counts
retracted with update, what retracting the old versions did
stopped, failed, batches a budget stop, the texts left out, the micro-batches
tenant, writes, dry_run the tenant, the write report, and whether it was a dry run

The graph's stats carry the report under "validation", beside each stage's counts under "stages", so a JSONL sink's manifest.json keeps it. Its JSON is versioned by schema_version ("1.1"), with openodke/validation_report.schema.json as its schema (Stability). report.manifest is the job's run manifest, kept out of the JSON and of equality.