Resolution & corroboration¶
After grounding, four stages in openodke.corroborate turn candidate facts into
a graph, in this order:
- Normalise, per document: one canonical spelling per value.
- Resolve, over the batch: which entity keys name the same thing.
- Corroborate: merge facts that are one claim, count independent sources, and decide contested values.
- Score: one confidence per fact.
Resolution comes first because Fact.signature merges on subject.key. All
four run on the base install and satisfy their Protocols in openodke.stages.
from datetime import UTC, datetime
from openodke import Entity, Evidence, Fact, GroundingVerdict, Ontology, SourceTier
from openodke.corroborate import (
EvidenceScorer,
NativeResolver,
SignatureCorroborator,
ValueNormalizer,
)
Nothing is overwritten: the reserved odke.* keys¶
Each stage records what it did under a reserved key, outside the signature
(DECISIONS #4). All are Fact.qualifiers keys except
odke.name_key, on Entity.attributes. Each key's constant is its name in
capitals (SOURCE_FORM), in openodke.corroborate, or openodke.reextract.
| Key | Written by | Holds |
|---|---|---|
odke.source_form |
ValueNormalizer |
{field: (original spelling, ...)} for each value rewritten; a merge keeps every spelling |
odke.name_key |
ValueNormalizer |
the name's comparison key; the label stays the display form |
odke.conflict |
SignatureCorroborator |
a contest's status (won, lost or tied) and reason, plus to, ratio and confidence_before for a loser |
odke.score |
EvidenceScorer |
the score's inputs: extractor, prior_used, verdict, support, conflict |
odke.derived |
the inverse step | rule (inverse or symmetric) and of, the stated fact's signature |
odke.widen |
LLMGrounder(widen=True) |
the widen-and-retry attempt |
odke.check |
the free checks | why they refused: check (predicate, domain or range) and reason |
odke.near_duplicates |
SignatureCorroborator |
the near-duplicate document groups |
odke.ontology, odke.schema_slice |
the gate; LLMExtractor |
fingerprints of the checking ontology and the model's slice |
odke.reextract |
the re-extract hook | the window the fact came from |
Rerunning the corroborator or scorer starts from the extractor's confidence, read back from these stamps, so a penalty never compounds.
Normalise¶
ValueNormalizer(ontology=None, *, day_first=None, person_types=()) rewrites
object_value and every qualifier value into one canonical form, so
"10 December 1815" and "1815-12-10" are one claim, and stamps each entity's
name key. Every rule leaves a value alone when unsure.
| Function | Reads | Canonical form |
|---|---|---|
normalize_date(text, *, day_first=None) |
ISO dates and datetimes, 10 December 1815, Dec 10, 1815, 03/04/2020, March 2020, 2020-03 |
1815-12-10, a month such as 2020-03, or an ISO datetime; None when unsure |
normalize_quantity(text) |
1,234, 5 km, 12.5 percent, €1.2bn, 1.5 GiB |
1234, "5000 m", "12.5 %", "1200000000 EUR", "1610612736 B"; None when unsure |
name_key(text, *, person=False) |
a name | casefolded, accents and punctuation dropped, S.A. → sa, & → and. Organisations lose a leading "the" and trailing legal forms; people are reordered from "Last, First" and lose honorifics and generational suffixes |
- Dates. An all-numeric day/month date is read only when one part is above
12 or both are equal, unless
day_firstsettles it. Interval bounds (start_time,start,end_time,end) are read only as dates. - Ranges. A
stringrange is left verbatim apart from whitespace, adaterange read only as a date, a numeric orquantityrange only as a quantity. - Units. A number with a unit becomes
"<number> <unit>"in metres, kilograms, seconds, bytes or%;KBis 1,000 bytes andKiB1,024, and a year is its own unit. Unit symbols are case-sensitive, scale words apply only to money, a leading zero marks an identifier, and numbers are read in English notation. Temperature,pound,ton, months and a decimal comma stay as written. Give a predicate with units the rangequantity;numberdrops the unit. - Currency is never converted. A symbol becomes an ISO code only when one
currency owns it (
€,£,₹,US$);$,¥andRsstay as written, after the amount. - People.
person_typesnames the types whose names are people's. Initials are kept.
from openodke.corroborate import name_key, normalize_date, normalize_quantity
assert normalize_date("Dec 10, 1815") == "1815-12-10"
assert normalize_date("03/04/2020") is None
assert normalize_date("03/04/2020", day_first=True) == "2020-04-03"
assert normalize_quantity("5 km") == normalize_quantity("5,000 metres") == "5000 m"
assert normalize_quantity("$1.2bn") == "1200000000 $" and normalize_quantity("5 °C") is None
assert name_key("Acme, Inc.") == name_key("The Acme Corporation") == "acme"
assert name_key("Lovelace, Ada", person=True) == "ada lovelace"
ontology = Ontology.from_dict(
{
"types": {"Person": {}, "Company": {}},
"predicates": {
"born": {"domain": ["Person"], "range": "date"},
"headquarters": {"domain": ["Company"]},
},
}
)
ada = Entity(key="p:ada", type="Person", label="Ada Lovelace")
born = ValueNormalizer(ontology, person_types=["Person"]).normalize(
Fact(subject=ada, predicate="born", object_value="10 December 1815")
)
print(born.object_value, born.qualifiers, born.subject.attributes)
# 1815-12-10 {'odke.source_form': {'object_value': ('10 December 1815',)}} {'odke.name_key': 'ada lovelace'}
Normalising a normalised fact changes nothing.
Resolve¶
NativeResolver(*, threshold=0.9, nudge_up=0.05, nudge_down=0.15, max_block=100,
lookup=None, judge=None, normalize_batch=False, embed=None, context_floor=0.5,
ontology=None) is the dependency-free resolver. It proposes links and changes
a key only on proof (Entities and links,
DECISIONS #16).
flowchart TD
e[entities in the batch and the index] --> block
block["block: same type, and a shared first or last name token, domain or external id"] --> strong
strong["compare strong identifiers: external ids of one scheme, domains among aliases"] --> weak
weak["score weak evidence: best name similarity, nudged by non-identifying attributes"] --> against
against{"a strong identifier disagrees?"}
against -- "yes, and there was a match to kill" --> different["DIFFERENT link, reason names both identifiers"]
against -- no --> agree{"a strong identifier agrees?"}
agree -- yes --> same["SAME_AS link: facts re-keyed onto one entity"]
agree -- no --> bar{"score at or above threshold?"}
bar -- yes --> similar["SIMILAR link: a proposal, no key changes"]
bar -- no --> nothing[no link]
- Blocking. Only entities of one type sharing a name's first or last token,
a domain or an external id are compared. A name block over
max_blockis split bycountry, and skipped if still too large.candidate_pairs(entities)returns the pairs blocking lets through. - Strong identifiers are
Entity.external_idand the domains among an entity's aliases (https://www.acme.com/isacme.com); a URL with a path is compared as a name. Ids of two schemes (wikidata:Q95) neither match nor disagree. - Weak evidence is name-key similarity: the better of a
difflibratio and a token overlap in which an initial counts half, plusnudge_upfor each shared attribute that agrees and minusnudge_downfor one that does not. - The disagreement rule. A disagreeing strong identifier makes a would-be
match
DIFFERENT, with areasonnaming both, including through a third entity. - A
SAME_ASre-keys facts onto one canonical entity, keeping the other keys, labels and aliases as its aliases: the caller's key, then one in the index, then one with anexternal_id, then the smallest. ASIMILARchanges no key.
from openodke import LinkKind
gmbh = Entity(
key="c:acme-gmbh", type="Company", label="Acme Corporation GmbH", external_id="DE-114322"
)
ltd = Entity(
key="c:acme-ltd", type="Company", label="Acme Corporation Ltd", external_id="GB-889401"
)
mention = Entity(
key="c:acme-corporation", type="Company", label="ACME Corporation", external_id="de-114322"
)
normalizer = ValueNormalizer(ontology)
facts = [
normalizer.normalize(Fact(subject=e, predicate="headquarters", object_value="Munich"))
for e in (gmbh, ltd, mention)
]
resolved, links = NativeResolver().resolve(facts, {})
for link in links:
print(link.kind.value, link.source_key, link.target_key, "|", link.reason)
# different c:acme-corporation c:acme-ltd | external_id mismatch: de-114322 vs GB-889401
# different c:acme-gmbh c:acme-ltd | external_id mismatch: DE-114322 vs GB-889401
# same_as c:acme-corporation c:acme-gmbh | external_id match: de-114322
merged = resolved[0].subject
assert resolved[2].subject.key == merged.key == "c:acme-corporation" # re-keyed on proof
assert merged.aliases == ("c:acme-gmbh", "Acme Corporation GmbH") # nothing lost
assert merged.resolution.method == "external_id"
assert resolved[1].subject.resolution.linker == "odke.native" # compared, not merged
assert LinkKind.SIMILAR not in {link.kind for link in links} # the ids settled every pair
Each returned subject's Entity.resolution is external_id for a merge on a
shared id, otherwise linker="odke.native" (score=1.0 on a shared domain);
one decided upstream keeps its own. The links become KnowledgeGraph.links.
Evaluation scores a resolver against your labels. For
an identifier only your corpus knows, stamp it on as external_id first, as
the end-to-end example's e2e_stages:RegistryResolver does.
Resolving against the store¶
NativeResolver(lookup=...) resolves the batch against the store without
loading it (DECISIONS #31). A StoreLookup returns, for each
entity, the stored entities of its type and tenant sharing a block key
(block_keys(entity)), judged by the rules above. A proof re-keys the incoming
facts onto the stored entity as stored, never the reverse; the SAME_AS keeps
the incoming key. A batch that states the stored key, or a caller's key
(method="caller"), keeps its own entity. Anything weaker is a link. Stored
entities are never compared with each other.
from openodke.corroborate import MemoryLookup
acme = Entity(key="c:acme", type="Company", label="Acme Corporation", aliases=("acme.com",))
widgets = Entity(key="c:widgets", type="Company", label="Acme Widgets")
store = MemoryLookup({e.key: e for e in (acme, widgets)})
batch = [
Entity(key="c:acme-inc", type="Company", label="ACME Inc.", aliases=("https://acme.com/",)),
Entity(key="c:acme-widget", type="Company", label="Acme Widget"),
]
resolver = NativeResolver(lookup=store)
resolved, links = resolver.resolve(
[Fact(subject=e, predicate="headquarters", object_value="Leeds") for e in batch], {}
)
for link in links:
print(link.kind.value, link.source_key, link.target_key, link.score)
# similar c:acme-widget c:widgets 0.9565
# same_as c:acme-inc c:acme 1.0
assert resolved[0].subject == acme # the stored entity, as stored
assert resolved[1].subject.key == "c:acme-widget" # a link, not a merge
assert resolver.stats["store"]["rekeyed"] == 1
MemoryLookup(index, *, tenant=None, tenant_property="tenant", limit=100,
embed=None, vector_k=5) is the store in memory. Neo4jLookup, or
Neo4jSink.lookup(**options), reads each batch in one transaction through the
indexes bootstrap() creates: the key constraint, external_id, and the
odke_names_<Type> full-text index (limit hits a token). A type without them
is not read, with a warning.
with Neo4jSink(uri, auth, ontology=ontology) as sink:
sink.bootstrap(ontology)
validator = Validator(ontology, lookup=sink.lookup(), sinks=[sink], tenant="acme")
kg, report = validator.validate(rows, documents) # report.store: what it found
A lookup is scoped to a tenant; MemoryLookup keeps the
entities whose tenant_property attribute equals tenant. With embed, a
function from texts to vectors, the vector_k nearest stored labels of the
type are candidates too (in Neo4j, from a vector_index you keep).
bench/store_lookup.py measures the SIMILAR path on Re-DocRED's 500 test
documents, each split into a store and a batch:
| threshold | links | link precision | link recall |
|---|---|---|---|
| 0.8 | 867 | 89.9% | 80.5% |
| 0.9 (default) | 807 | 95.3% | 79.4% |
| 1.0 | 776 | 97.9% | 78.5% |
Across documents: T-REx. In a run
config: store_lookup.
The pair judge¶
Provisional (Stability).
NativeResolver(judge=PairJudge(...)) asks a model about the pairs the rules
leave open: no id or domain settled them, and their name score is from the
judge's low (0.7) up to the resolver's threshold
(DECISIONS #34).
PairJudge(roles=None, *, client=None, low=0.7, queue=None, reviewed=None,
store_context=None, max_workers=8, retry=None):
- Both orders. Each pair is asked as (A, B) and (B, A). "same" counts only when both say same, "different" only when both say different; anything else is unsure.
- Links. "same" is a
SIMILARlink whose reason names the judge, prompt and model, never a merge; "different" aDIFFERENTlink. Unsure, or a failed call, makes none, and the next run asks again. - Context. The prompts
pair@1andpair.user@1(Prompts) show each mention's name, type, and sentence with one either side, fromjudge.documents(filled byodke runand the Validator) or the span's quote. A stored entity shows its aliases andstore_context(entity). A side with no context is not asked. - Review. With
queue=, each unsure pair is appended once to a JSONL file forodke label make pair. Labels read back byodke label readarereviewed=: a person decides in the judge's place, and the link's reason startsperson:. - The model is the
groundrole's, retried as the grounder is. A missing key raises; a budget stop leaves the pairs it reachedunasked.
from openodke import Document, Span
from openodke.corroborate import PairJudge
from openodke.llm import RecordedClient
bio = Document(
id="bio",
text="Ada Lovelace wrote the first program. She worked with Charles Babbage. "
"Lovelace died in London in 1852.",
)
def cited(entity, quote):
start = bio.text.index(quote)
span = Span(doc_id="bio", start=start, end=start + len(quote))
return Fact(
subject=entity, predicate="mentioned", object_value=quote,
evidence=(Evidence(doc_id="bio", span=span),),
) # fmt: skip
ada = Entity(key="p:ada", type="Person", label="Ada Lovelace")
surname = Entity(key="p:lovelace", type="Person", label="Lovelace")
# Recorded answers, by the mention each order names first. Drop `client=` to call a model.
client = RecordedClient(
[
{"match": 'Mention A: "Ada', "response": {"because": "one woman", "decision": "same"}},
{"match": 'Mention A: "Lov', "response": {"because": "her surname", "decision": "same"}},
]
)
judge = PairJudge(client=client)
judge.documents[bio.id] = bio
resolver = NativeResolver(judge=judge)
facts = [cited(ada, "Ada Lovelace wrote the first program."), cited(surname, "Lovelace died")]
resolved, links = resolver.resolve(facts, {})
for link in links:
print(link.kind.value, link.source_key, link.target_key, link.score)
print(link.reason)
# similar p:ada p:lovelace 0.8
# pair judge (pair@1, anthropic/claude-haiku-4-5-20251001): same in both orders; one woman
assert [f.subject.key for f in resolved] == ["p:ada", "p:lovelace"] # a link, not a merge
assert resolver.stats["judge"]["calls"] == 2 and resolver.stats["judge"]["swapped"] == 1
The first call sent this, and the second the same with the mentions swapped:
Mention A: "Ada Lovelace" (type: Person)
Context A: Ada Lovelace wrote the first program. She worked with Charles Babbage.
Mention B: "Lovelace" (type: Person)
Context B: She worked with Charles Babbage. Lovelace died in London in 1852.
resolver.stats["judge"] counts the pairs handed in and asked, calls,
swapped, disagreed, each decision, person, queued, no_context,
failed, unparseable, tokens, cost, cached, and after a budget stop
unasked and stopped. judge.decisions keeps every PairDecision. In a run
config: resolver: {use: native, judge: …}. Where 0.7
comes from: DECISIONS #34.
Normalising mentions in a batch¶
Provisional (Stability), off by default.
NativeResolver(normalize_batch=True, embed=None, context_floor=0.5,
ontology=None) re-keys two mentions the batch introduces onto one entity when
they look alike and nothing keeps them apart (DECISIONS #43).
A key the store holds is never part of it.
- Normalise and block as resolution does. Given
ontology, a type's aliases are that type, so anorganisationmention is aCompany. - Compare. A pair no id or domain settled, scoring at the
threshold, merges unless both names carry numbers that differ (Robert II,Robert III) or legal forms that differ (Acme GmbH,Acme Ltd), or, givenembed, the cosine of the mentions' sentences is belowcontext_floor. - Judge. In the band below, a pair judge's or a person's "same" merges the pair unless the names differ so; "different" keeps it apart.
- Merge. One canonical entity per group, chosen as above but preferring an
entity typed by the ontology's own name to the smallest key, with
resolutionat the weakest score it was joined at.
Merges go strongest first. One joining two disagreeing ids, two caller keys or
two mentions kept apart is refused, and a mention alike to two entities kept
apart joins neither. A merge is a SAME_AS with a reason starting
in-batch merge:; a pair kept apart keeps its SIMILAR, with not merged:
and why.
sky = Document(
id="sky",
text="Mercury is the planet closest to the Sun. Mercury is a metal that stays "
"liquid at room temperature. Mercury takes 88 days to orbit the Sun.",
)
def mention(key, quote):
start = sky.text.index(quote)
span = Span(doc_id="sky", start=start, end=start + len(quote))
entity = Entity(key=key, type="Thing", label="Mercury")
return Fact(
subject=entity, predicate="mentioned", object_value=quote,
evidence=(Evidence(doc_id="sky", span=span),),
) # fmt: skip
def embed(texts): # stands in for a model: a sentence about orbits, or about metals
return [[float("planet" in t or "orbit" in t), float("metal" in t)] for t in texts]
facts = [
mention("t:planet", "Mercury is the planet"),
mention("t:metal", "Mercury is a metal"),
mention("t:orbit", "Mercury takes 88 days"),
]
resolver = NativeResolver(normalize_batch=True, embed=embed)
resolver.documents[sky.id] = sky
resolved, links = resolver.resolve(facts, {})
for link in links:
print(link.kind.value, link.source_key, link.target_key, "|", link.reason)
# similar t:metal t:orbit | not merged: the contexts differ (0.0000 < 0.5)
# similar t:metal t:planet | not merged: the contexts differ (0.0000 < 0.5)
# same_as t:orbit t:planet | in-batch merge: names 1.0, contexts 1.0000
planet = resolved[0].subject
assert [f.subject.key for f in resolved] == ["t:orbit", "t:metal", "t:orbit"]
assert planet.aliases == ("t:planet",) and planet.resolution.score == 1.0
print(resolver.stats["batch"])
# {'alike': 3, 'merged': 1, 'groups': 1, 'mentions': 2, 'numbers': 0, 'forms': 0, 'context': 2, 'ambiguous': 0, 'refused': 0, 'embedded': 3}
stats["batch"] counts alike pairs, those merged, the groups and
mentions made, pairs kept apart by numbers, forms, context, as
ambiguous or refused, and sentences embedded. In a run config:
resolver: {use: native, normalize_batch: true}; embed is
Python only. Why it is off, measured by bench/batch_normalize.py:
DECISIONS #43.
Corroborate¶
SignatureCorroborator(ontology=None, *, source=source_of, half_life_days=365.0,
freshness_floor=0.5, intervals=DEFAULT_INTERVALS, documents=None,
near_duplicates=0.9, store=None) merges claims, then contests values. With
store, one FactLookup or several, it also
merges with the store in between.
Merge by signature¶
Facts sharing a signature are one claim: a denial never merges with its
assertion, nor a p50 value with a p95 one
(DECISIONS #14, #15). The rest is
reconciled:
- Evidence is unioned, except from a text that contradicts a claim another
member supports. That text is no source for the claim: its evidence is left
out, and its qualifiers, valid time, confidence and extractor are not the
claim's.
odke.conflictrecords its documents ascontradicted_in(statuscontradicted, or beside a contest's decision), and a merge whose texts disagree records them even with no support, so a later merge leaves out those texts alone. The record outlives later merges, and goes for a document that comes back supporting the claim. Delete the supporting text, and the fact is retired, not left standing on the one that contradicts it. supportcounts independent sources:source_of(evidence)is the URI's host withoutwww., else the document. Passsource=to decide otherwise.supported_bynames them: the support list.- Qualifiers agree, or take the interval union for the keys in
intervals(earlieststart_timeandstart, latestend_timeandend), or the best-ranked source's value. Everyodke.source_formspelling is kept. - Valid time is the earliest
valid_fromand the latestvalid_to. verdictis the most informative: one supporting span supports the claim, else a contradiction outweighs silence.confidenceis the members' maximum, andextractorjoins their names.
Near-duplicate sources¶
Given the texts (documents=, by Document.id), two documents from different
sources behind one claim count once when the Jaccard similarity of their 5-word
shingles is at least near_duplicates (0.9; None turns it off). Only
documents behind the same claim are compared. Every evidence is kept,
odke.near_duplicates names the group, and the group's tier is its best
member's. odke run and the Validator pass the texts they load, and
stats["near_duplicates"] counts the pairs compared and found.
Support lists¶
Fact.supported_by is a tuple of Support, one per independent source, so
support == len(supported_by) when it is filled
(DECISIONS #33). The reconciler
reads it when a source changes.
Support field |
Holds |
|---|---|
source |
the key grouped on: a host, doc:<id> for a document with no URI, or a near-duplicate group's least key |
doc_ids |
that source's documents the fact cites, sorted |
tier |
the best tier among their evidence |
retrieved_at |
the newest clock among their evidence |
support_of(evidence, groups, source) builds the list, sorted by source. A
derived fact shares its parent's (DECISIONS #28). A claim
with a member that cites nothing, and a fact no corroborator merged, keep the
count and an empty list. Every sink writes it
(provenance).
from openodke import Support
two = [
Fact(
subject=ada,
predicate="born",
object_value="1815-12-10",
evidence=(Evidence(doc_id=doc, uri=uri, retrieved_at=datetime(2026, 9, day, tzinfo=UTC)),),
)
for doc, uri, day in (
("bio.md", "https://www.example.org/ada", 1),
("register.csv#L2", None, 2),
)
]
(born,) = SignatureCorroborator().corroborate(two)
for entry in born.supported_by:
print(entry.source, entry.doc_ids, entry.retrieved_at.date())
# doc:register.csv#L2 ('register.csv#L2',) 2026-09-02
# example.org ('bio.md',) 2026-09-01
assert born.support == len(born.supported_by) == 2
assert isinstance(born.supported_by[0], Support)
Contest¶
Two claims conflict when they share subject and predicate, the predicate is
single, they agree on its scope_keys,
their objects differ, and their valid intervals overlap. A denial and an
assertion of one object conflict on any predicate. Without an ontology, or for
a predicate it does not declare, no value is contested. A value that changed
over time is not a conflict (DECISIONS #17).
rank = trust × agreement
trust = max over its evidence of tier.weight × freshness
freshness = floor + (1 − floor) × 0.5 ^ (age / half_life)
agreement = 1 + ln(1 + Σ_s 1 / (1 + ln n_s))
age is measured back from the newest evidence in the batch, and n_s is how
many claims source s backs in the batch, so a source that asserts much has a
smaller vote (Pasternack and Roth's Average·Log). With the default floor,
freshness at most halves trust.
The highest rank wins. A loser is kept: its confidence is multiplied by
rank / winning rank, and odke.conflict says why. Equal ranks are tied, at
full confidence.
retrieved = datetime(2026, 9, 1, tzinfo=UTC)
halden = Entity(key="c:halden", type="Company", label="Halden Robotics Ltd")
def cited(doc_id, tier, uri=None):
return (Evidence(doc_id=doc_id, uri=uri, tier=tier, retrieved_at=retrieved),)
candidates = [
Fact(
subject=halden,
predicate="headquarters",
object_value="Leeds",
evidence=cited("register.csv#L2", SourceTier.CURATED),
confidence=1.0,
),
Fact(
subject=halden,
predicate="headquarters",
object_value="Leeds",
evidence=cited("factsheet.md", SourceTier.AUTHORITATIVE),
confidence=1.0,
),
Fact(
subject=halden,
predicate="headquarters",
object_value="Sheffield",
evidence=cited("halden.md", SourceTier.COMMUNITY, "https://trade.example/halden"),
confidence=0.5,
verdict=GroundingVerdict.SUPPORTED,
),
]
leeds, sheffield = SignatureCorroborator(ontology).corroborate(candidates)
assert (leeds.support, leeds.qualifiers["odke.conflict"]["status"]) == (2, "won")
lost = sheffield.qualifiers["odke.conflict"]
assert (lost["status"], lost["to"], lost["ratio"]) == ("lost", "'Leeds'", 0.4034)
assert round(sheffield.confidence, 4) == round(0.5 * 0.4034, 4)
print(lost["reason"])
# Kept 'Leeds' over 'Sheffield': 'Leeds' is backed by 2 independent sources (the strongest curated, retrieved 2026-09-01) and 'Sheffield' by 1 independent source (community, retrieved 2026-09-01). Ranked on source trust, freshness, and agreement discounted by how much each source asserts: 2.10 to 0.85.
Score¶
EvidenceScorer(*, prior=0.5, verdict_weights=DEFAULT_VERDICT_WEIGHTS,
source=source_of) sets Fact.confidence, monotone and bounded in [0, 1]:
confidence = g(verdict) × (1 − (1 − c) ^ (1 + ln support)) × k
cis the extractor's confidence, clamped to [0, 1]; exactly 0.0 means it reported none, andpriorstands in.-
gis the verdict's weight:verdict gsupported1.0 unchecked0.6 not_found0.25 contradicted0.05 -
supportis the corroborator's count of independent sources, or the fact's own, whichever is larger; a listed count is never recounted. kis 1, or a contest loser'sratio.
The inputs are kept under odke.score; scoring twice gives the same number.
from openodke.corroborate import combine
# Worked values at extractor confidence 0.8.
assert round(combine(0.8, 1.0, support=1), 2) == 0.80 # one source, supported
assert round(combine(0.8, 0.6, support=1), 2) == 0.48 # one source, unchecked
assert round(combine(0.8, 0.25, support=1), 2) == 0.20 # one source, not found
assert round(combine(0.8, 1.0, support=3), 2) == 0.97 # three sources, supported
assert round(combine(0.8, 1.0, support=1, conflict=0.35), 2) == 0.28 # lost a contest
scored = EvidenceScorer().score(sheffield)
print(round(scored.confidence, 4), scored.qualifiers["odke.score"])
# 0.2017 {'extractor': 0.5, 'prior_used': False, 'verdict': 1.0, 'support': 1, 'conflict': 0.4034}
The score orders facts; it is not a probability until measured. To pick a
threshold, odke eval score reports the Brier score, a reliability curve and
ECE against a slice you label (Evaluation). Before there
are labels, 0.5 keeps a confident, supported single-source fact and drops an
unchecked single-source one and every contest loser.
In a pipeline¶
from openodke import Document, Pipeline
class Replay:
"""Stands in for an extractor: the three candidates above."""
def extract(self, chunk, ontology):
return candidates
pipeline = Pipeline(
ontology,
Replay(),
normalizer=ValueNormalizer(ontology),
resolver=NativeResolver(),
corroborator=SignatureCorroborator(ontology),
scorer=EvidenceScorer(),
)
graph = pipeline.run([Document(text="Halden Robotics Ltd has its head office in Leeds.")])
assert [(f.object_value, f.support) for f in graph.facts] == [("Leeds", 2), ("Sheffield", 1)]
In an odke run config the four are normalizer: value,
resolver: native, corroborator: signature and scorer: evidence, with
their options as extra keys. A run's contested values show in its stats as
corroborator: conflicts (lost 1, won 2).