evidence-graph 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- evidence_graph/__init__.py +198 -0
- evidence_graph/adjudicate.py +399 -0
- evidence_graph/biolink.py +133 -0
- evidence_graph/conflicts.py +364 -0
- evidence_graph/data/MANIFEST.json +62 -0
- evidence_graph/data/NOTICE +49 -0
- evidence_graph/data/chebi.tsv.gz +0 -0
- evidence_graph/data/cl.tsv.gz +0 -0
- evidence_graph/data/go.tsv.gz +0 -0
- evidence_graph/data/hgnc.tsv.gz +0 -0
- evidence_graph/data/mondo.tsv.gz +0 -0
- evidence_graph/data/redirects.tsv.gz +0 -0
- evidence_graph/data/uberon.tsv.gz +0 -0
- evidence_graph/data/uniprot.tsv.gz +0 -0
- evidence_graph/evaluation.py +230 -0
- evidence_graph/evidence.py +47 -0
- evidence_graph/fair.py +307 -0
- evidence_graph/graph.py +194 -0
- evidence_graph/grounding.py +323 -0
- evidence_graph/kgx.py +382 -0
- evidence_graph/maintain.py +279 -0
- evidence_graph/ontology.py +121 -0
- evidence_graph/pipeline.py +196 -0
- evidence_graph/provenance.py +282 -0
- evidence_graph/py.typed +0 -0
- evidence_graph/relations.py +395 -0
- evidence_graph/ro_crate_validation.py +149 -0
- evidence_graph-0.1.1.dist-info/METADATA +234 -0
- evidence_graph-0.1.1.dist-info/RECORD +32 -0
- evidence_graph-0.1.1.dist-info/WHEEL +4 -0
- evidence_graph-0.1.1.dist-info/licenses/LICENSE +202 -0
- evidence_graph-0.1.1.dist-info/licenses/NOTICE +21 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""evidence-graph: knowledge graphs whose edges carry their evidence, or abstain.
|
|
2
|
+
|
|
3
|
+
The library takes retrieved passages with provenance (`RetrievalHit`) and produces structure that a
|
|
4
|
+
scientist can check: typed edges between documents, recorded disagreements, a tamper-evident
|
|
5
|
+
derivation record, and a FAIR-packaged export.
|
|
6
|
+
|
|
7
|
+
It never opens a socket, never reads a credential, and has no runtime dependencies. Anything that
|
|
8
|
+
needs a model is injected by the caller.
|
|
9
|
+
|
|
10
|
+
Layers, roughly in the order you would use them:
|
|
11
|
+
|
|
12
|
+
evidence RetrievalHit — a passage plus the provenance needed to cite it
|
|
13
|
+
biolink Biolink Model categories, predicates, and CURIE typing
|
|
14
|
+
grounding resolve a surface string to a biomedical CURIE, or refuse
|
|
15
|
+
ontology type a concept term against a small built-in vocabulary
|
|
16
|
+
relations propose typed document→document edges, each carrying its evidence
|
|
17
|
+
conflicts detect numeric and documented disagreement between sources
|
|
18
|
+
adjudicate assert / abstain / conflict per candidate edge, over a scorer Protocol
|
|
19
|
+
maintain re-run without overwriting: revision history per edge
|
|
20
|
+
evaluation edge-level precision as a function of abstention (risk-coverage)
|
|
21
|
+
graph assemble nodes and edges into a graph payload
|
|
22
|
+
provenance W3C PROV-O derivation records with SHA-256 tamper-evidence
|
|
23
|
+
kgx KGX interchange: Biolink-compatible TSV and JSON Lines
|
|
24
|
+
fair DataCite report, Dublin Core XML, RO-Crate 1.1 JSON-LD
|
|
25
|
+
ro_crate_validation structural validation of an RO-Crate against the 1.1 spec
|
|
26
|
+
|
|
27
|
+
Status: 0.1.1. The API will change. See CONTRIBUTING.md for what belongs here.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from evidence_graph.adjudicate import (
|
|
33
|
+
DEFAULT_ASSERT_AT,
|
|
34
|
+
DEFAULT_SCORER,
|
|
35
|
+
Adjudication,
|
|
36
|
+
AdjudicationResult,
|
|
37
|
+
EdgeScore,
|
|
38
|
+
EdgeScorer,
|
|
39
|
+
GroundingScorer,
|
|
40
|
+
Verdict,
|
|
41
|
+
adjudicate,
|
|
42
|
+
edge_key,
|
|
43
|
+
open_questions,
|
|
44
|
+
)
|
|
45
|
+
from evidence_graph.biolink import (
|
|
46
|
+
BIOLINK_VERSION,
|
|
47
|
+
CATEGORIES,
|
|
48
|
+
DEFAULT_CATEGORY,
|
|
49
|
+
category_chain,
|
|
50
|
+
category_for_curie,
|
|
51
|
+
category_for_prefix,
|
|
52
|
+
)
|
|
53
|
+
from evidence_graph.conflicts import (
|
|
54
|
+
detect_conflict,
|
|
55
|
+
detect_documented_conflict,
|
|
56
|
+
detect_numeric_conflict,
|
|
57
|
+
detect_numeric_conflict_any,
|
|
58
|
+
)
|
|
59
|
+
from evidence_graph.evaluation import (
|
|
60
|
+
CurvePoint,
|
|
61
|
+
EvaluationReport,
|
|
62
|
+
baseline_precision,
|
|
63
|
+
risk_coverage,
|
|
64
|
+
risk_coverage_svg,
|
|
65
|
+
)
|
|
66
|
+
from evidence_graph.evidence import RetrievalHit
|
|
67
|
+
from evidence_graph.fair import (
|
|
68
|
+
DEFAULT_PRODUCER,
|
|
69
|
+
Producer,
|
|
70
|
+
dublin_core_xml,
|
|
71
|
+
fair_record,
|
|
72
|
+
fair_report,
|
|
73
|
+
render_dublin_core,
|
|
74
|
+
ro_crate,
|
|
75
|
+
)
|
|
76
|
+
from evidence_graph.graph import build_project_graph
|
|
77
|
+
from evidence_graph.grounding import (
|
|
78
|
+
Concept,
|
|
79
|
+
Grounding,
|
|
80
|
+
GroundingReport,
|
|
81
|
+
Vocabulary,
|
|
82
|
+
ground,
|
|
83
|
+
ground_all,
|
|
84
|
+
load_vocabulary,
|
|
85
|
+
normalize_term,
|
|
86
|
+
)
|
|
87
|
+
from evidence_graph.kgx import (
|
|
88
|
+
KgxGraph,
|
|
89
|
+
from_adjudications,
|
|
90
|
+
to_jsonl,
|
|
91
|
+
to_tsv,
|
|
92
|
+
validate_kgx,
|
|
93
|
+
)
|
|
94
|
+
from evidence_graph.maintain import (
|
|
95
|
+
EdgeHistory,
|
|
96
|
+
EdgeStatus,
|
|
97
|
+
History,
|
|
98
|
+
Revision,
|
|
99
|
+
changelog,
|
|
100
|
+
contested_edges,
|
|
101
|
+
evidence_fingerprint,
|
|
102
|
+
live_edges,
|
|
103
|
+
reconcile,
|
|
104
|
+
)
|
|
105
|
+
from evidence_graph.ontology import ONTOLOGY_TYPES, classify_concept
|
|
106
|
+
from evidence_graph.provenance import (
|
|
107
|
+
PROV_SCHEMA_VERSION,
|
|
108
|
+
ProvenanceEntry,
|
|
109
|
+
answer_lineage,
|
|
110
|
+
compute_checksum,
|
|
111
|
+
finalize,
|
|
112
|
+
lineage_report,
|
|
113
|
+
prov_jsonld,
|
|
114
|
+
prov_jsonld_nodes,
|
|
115
|
+
trace_lineage,
|
|
116
|
+
verify_checksum,
|
|
117
|
+
verify_lineage,
|
|
118
|
+
)
|
|
119
|
+
from evidence_graph.relations import extract_typed_relations, normalize_entity
|
|
120
|
+
from evidence_graph.ro_crate_validation import validate_ro_crate
|
|
121
|
+
|
|
122
|
+
__version__ = "0.1.1"
|
|
123
|
+
|
|
124
|
+
__all__ = [
|
|
125
|
+
"Adjudication",
|
|
126
|
+
"AdjudicationResult",
|
|
127
|
+
"BIOLINK_VERSION",
|
|
128
|
+
"CATEGORIES",
|
|
129
|
+
"Concept",
|
|
130
|
+
"CurvePoint",
|
|
131
|
+
"DEFAULT_ASSERT_AT",
|
|
132
|
+
"DEFAULT_CATEGORY",
|
|
133
|
+
"DEFAULT_PRODUCER",
|
|
134
|
+
"DEFAULT_SCORER",
|
|
135
|
+
"EdgeHistory",
|
|
136
|
+
"EdgeScore",
|
|
137
|
+
"EdgeScorer",
|
|
138
|
+
"EdgeStatus",
|
|
139
|
+
"EvaluationReport",
|
|
140
|
+
"Grounding",
|
|
141
|
+
"GroundingReport",
|
|
142
|
+
"GroundingScorer",
|
|
143
|
+
"History",
|
|
144
|
+
"KgxGraph",
|
|
145
|
+
"ONTOLOGY_TYPES",
|
|
146
|
+
"PROV_SCHEMA_VERSION",
|
|
147
|
+
"Producer",
|
|
148
|
+
"ProvenanceEntry",
|
|
149
|
+
"RetrievalHit",
|
|
150
|
+
"Revision",
|
|
151
|
+
"Verdict",
|
|
152
|
+
"Vocabulary",
|
|
153
|
+
"adjudicate",
|
|
154
|
+
"answer_lineage",
|
|
155
|
+
"baseline_precision",
|
|
156
|
+
"build_project_graph",
|
|
157
|
+
"category_chain",
|
|
158
|
+
"category_for_curie",
|
|
159
|
+
"category_for_prefix",
|
|
160
|
+
"changelog",
|
|
161
|
+
"classify_concept",
|
|
162
|
+
"compute_checksum",
|
|
163
|
+
"contested_edges",
|
|
164
|
+
"detect_conflict",
|
|
165
|
+
"detect_documented_conflict",
|
|
166
|
+
"detect_numeric_conflict",
|
|
167
|
+
"detect_numeric_conflict_any",
|
|
168
|
+
"dublin_core_xml",
|
|
169
|
+
"edge_key",
|
|
170
|
+
"evidence_fingerprint",
|
|
171
|
+
"extract_typed_relations",
|
|
172
|
+
"fair_record",
|
|
173
|
+
"fair_report",
|
|
174
|
+
"finalize",
|
|
175
|
+
"from_adjudications",
|
|
176
|
+
"ground",
|
|
177
|
+
"ground_all",
|
|
178
|
+
"lineage_report",
|
|
179
|
+
"live_edges",
|
|
180
|
+
"load_vocabulary",
|
|
181
|
+
"normalize_entity",
|
|
182
|
+
"normalize_term",
|
|
183
|
+
"open_questions",
|
|
184
|
+
"prov_jsonld",
|
|
185
|
+
"prov_jsonld_nodes",
|
|
186
|
+
"reconcile",
|
|
187
|
+
"render_dublin_core",
|
|
188
|
+
"risk_coverage",
|
|
189
|
+
"risk_coverage_svg",
|
|
190
|
+
"ro_crate",
|
|
191
|
+
"to_jsonl",
|
|
192
|
+
"to_tsv",
|
|
193
|
+
"trace_lineage",
|
|
194
|
+
"validate_kgx",
|
|
195
|
+
"validate_ro_crate",
|
|
196
|
+
"verify_checksum",
|
|
197
|
+
"verify_lineage",
|
|
198
|
+
]
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Assert, abstain, or record a conflict — the verdict step between proposing an edge and keeping it.
|
|
2
|
+
|
|
3
|
+
A proposer (`relations.extract_typed_relations`, or a model) is optimistic by construction: it emits
|
|
4
|
+
every edge its patterns fire on. That is the right behaviour for a proposer and the wrong behaviour
|
|
5
|
+
for a graph, because a plausible edge and a supported edge are indistinguishable once they are in
|
|
6
|
+
the store. This module is the gate in between.
|
|
7
|
+
|
|
8
|
+
Every candidate gets one of three verdicts:
|
|
9
|
+
|
|
10
|
+
ASSERT the evidence carries the claim; the edge goes into the graph.
|
|
11
|
+
ABSTAIN the evidence does not reach; the edge is kept as an OPEN QUESTION with its evidence
|
|
12
|
+
attached, so a curator can confirm or reject it. It is never silently dropped — an
|
|
13
|
+
abstention is a finding, and discarding it throws away the only record that the system
|
|
14
|
+
looked and was not convinced.
|
|
15
|
+
CONFLICT the candidate set disagrees with itself about this pair of documents (one edge says
|
|
16
|
+
they agree on a quantity, another says they disagree). Both are kept, side by side,
|
|
17
|
+
rather than resolved by picking the higher score.
|
|
18
|
+
|
|
19
|
+
**Scoring is pluggable and the default is keyless.** `EdgeScorer` is a Protocol: given an edge and
|
|
20
|
+
its evidence, return a score and a rationale. The default `GroundingScorer` runs no model. It asks a
|
|
21
|
+
narrower question than "is this edge true" — it asks **"is this edge actually grounded in the text
|
|
22
|
+
it cites?"**: does the evidence exist, does each quote really contain the trigger the proposer
|
|
23
|
+
claims fired, do both endpoints carry evidence, does the quote show the cue the edge type implies.
|
|
24
|
+
|
|
25
|
+
That distinction is the honest part. Verbatim grounding is necessary and not sufficient for
|
|
26
|
+
correctness: a perfectly grounded edge can still be a wrong reading of the sentence. An LLM scorer
|
|
27
|
+
supplied by the caller adds semantic judgement on top. The library defines the seam and implements
|
|
28
|
+
the half that needs no key, so the pipeline degrades to a weaker but still correct graph offline.
|
|
29
|
+
|
|
30
|
+
`assert_at` is the abstention dial. Raising it asserts fewer edges at higher precision; the
|
|
31
|
+
trade-off it makes is measurable with `evidence_graph.evaluation.risk_coverage`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from enum import StrEnum
|
|
39
|
+
from typing import Any, Protocol
|
|
40
|
+
|
|
41
|
+
# Default score floor for assertion, on the 0-10 scale every scorer reports. 6.0 sits above
|
|
42
|
+
# "evidence exists and is well formed" (which the grounding scorer alone can reach) and below
|
|
43
|
+
# "every check passed", so a structurally sound but weakly cued edge abstains rather than asserts.
|
|
44
|
+
DEFAULT_ASSERT_AT = 6.0
|
|
45
|
+
|
|
46
|
+
# Edge types that make incompatible claims about the same pair of documents and quantity. If a
|
|
47
|
+
# proposer emits both, the truth is not "whichever scored higher" — it is that the corpus disagrees.
|
|
48
|
+
INCOMPATIBLE_TYPES = frozenset({"supports", "contradicts"})
|
|
49
|
+
|
|
50
|
+
# Edge types whose claim is a COMPARISON between two documents, and which therefore have to cite
|
|
51
|
+
# evidence from both. `decided` and `uses-protocol` are one-sided by construction — the deciding
|
|
52
|
+
# document is the only one that records the decision — so demanding evidence from both ends
|
|
53
|
+
# penalises them for being correctly formed. Found on a real corpus, where it accounted for 4433
|
|
54
|
+
# abstentions.
|
|
55
|
+
TWO_SIDED_TYPES = frozenset({"supports", "contradicts"})
|
|
56
|
+
|
|
57
|
+
# Surface cues an edge type implies. Used by the grounding scorer to check that the quote shows
|
|
58
|
+
# what the edge claims, not merely that a quote exists.
|
|
59
|
+
_TYPE_CUES: dict[str, re.Pattern[str]] = {
|
|
60
|
+
"decided": re.compile(
|
|
61
|
+
r"\b(?:decide[sd]?|decision|chose|choose|selected?|opt(?:ed|s)?\s+for|conclude[sd]?|"
|
|
62
|
+
r"recommend(?:s|ed)?|approve[sd]?|finali[sz]ed?|went\s+with|settled\s+on)\b",
|
|
63
|
+
re.IGNORECASE,
|
|
64
|
+
),
|
|
65
|
+
"uses-protocol": re.compile(r"\b(?:sop|protocol|s\.?o\.?p\.?)\b", re.IGNORECASE),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Verdict(StrEnum):
|
|
70
|
+
"""What the adjudicator concluded about one candidate edge."""
|
|
71
|
+
|
|
72
|
+
ASSERT = "assert"
|
|
73
|
+
ABSTAIN = "abstain"
|
|
74
|
+
CONFLICT = "conflict"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class EdgeScore:
|
|
79
|
+
"""A scorer's judgement of one candidate edge.
|
|
80
|
+
|
|
81
|
+
score: 0-10. Scorers must use this scale so thresholds and risk-coverage curves are comparable
|
|
82
|
+
between a deterministic scorer and a model.
|
|
83
|
+
rationale: why, in one short human-readable line. This is shown to the curator reviewing an
|
|
84
|
+
abstention, so "no evidence carries the trigger 'ph=5.5'" beats "score 2.0".
|
|
85
|
+
checks: per-check detail, for a scorer that runs several. Free-form and advisory.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
score: float
|
|
89
|
+
rationale: str
|
|
90
|
+
checks: dict[str, bool] = field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class EdgeScorer(Protocol):
|
|
94
|
+
"""How the caller supplies judgement.
|
|
95
|
+
|
|
96
|
+
An implementation receives the candidate edge (the shape `relations.extract_typed_relations`
|
|
97
|
+
emits) and returns an `EdgeScore`. It must be deterministic for a given input if the caller
|
|
98
|
+
wants reproducible graphs; a model-backed scorer usually is not, which is why the library
|
|
99
|
+
default is not one.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __call__(self, edge: dict[str, Any]) -> EdgeScore: ...
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Adjudication:
|
|
107
|
+
"""One candidate edge plus the verdict on it, and everything needed to explain the verdict."""
|
|
108
|
+
|
|
109
|
+
edge: dict[str, Any]
|
|
110
|
+
verdict: Verdict
|
|
111
|
+
score: float
|
|
112
|
+
rationale: str
|
|
113
|
+
checks: dict[str, bool] = field(default_factory=dict)
|
|
114
|
+
# Populated for CONFLICT: the other adjudications this one disagrees with.
|
|
115
|
+
conflicts_with: tuple[str, ...] = ()
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def key(self) -> str:
|
|
119
|
+
"""Stable identity for this edge: source, type, target, quantity."""
|
|
120
|
+
return edge_key(self.edge)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def asserted(self) -> bool:
|
|
124
|
+
return self.verdict is Verdict.ASSERT
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class AdjudicationResult:
|
|
129
|
+
"""The outcome of adjudicating a candidate set, partitioned by verdict.
|
|
130
|
+
|
|
131
|
+
`abstained` is deliberately as prominent as `asserted`. It is the review queue, and a pipeline
|
|
132
|
+
that ignores it is throwing away the system's own record of what it could not establish.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
adjudications: tuple[Adjudication, ...]
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def asserted(self) -> tuple[Adjudication, ...]:
|
|
139
|
+
return tuple(a for a in self.adjudications if a.verdict is Verdict.ASSERT)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def abstained(self) -> tuple[Adjudication, ...]:
|
|
143
|
+
return tuple(a for a in self.adjudications if a.verdict is Verdict.ABSTAIN)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def conflicted(self) -> tuple[Adjudication, ...]:
|
|
147
|
+
return tuple(a for a in self.adjudications if a.verdict is Verdict.CONFLICT)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def coverage(self) -> float:
|
|
151
|
+
"""Share of candidates that were asserted. The x-axis of a risk-coverage curve."""
|
|
152
|
+
if not self.adjudications:
|
|
153
|
+
return 0.0
|
|
154
|
+
return len(self.asserted) / len(self.adjudications)
|
|
155
|
+
|
|
156
|
+
def summary(self) -> dict[str, Any]:
|
|
157
|
+
return {
|
|
158
|
+
"candidates": len(self.adjudications),
|
|
159
|
+
"asserted": len(self.asserted),
|
|
160
|
+
"abstained": len(self.abstained),
|
|
161
|
+
"conflicted": len(self.conflicted),
|
|
162
|
+
"coverage": round(self.coverage, 4),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def edge_key(edge: dict[str, Any]) -> str:
|
|
167
|
+
"""Stable identity for an edge: `source|type|target|quantity`."""
|
|
168
|
+
return "|".join(
|
|
169
|
+
str(edge.get(k, "")) for k in ("source", "type", "target", "quantity")
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _pair_key(edge: dict[str, Any]) -> tuple[str, str, str]:
|
|
174
|
+
"""Identity of the *claim* an edge makes, ignoring its direction and its type.
|
|
175
|
+
|
|
176
|
+
`supports` and `contradicts` between the same two documents about the same quantity are claims
|
|
177
|
+
about the same thing, so they collide here on purpose — that collision is how a conflict is
|
|
178
|
+
found. Direction is dropped because "A agrees with B" and "B agrees with A" are one claim.
|
|
179
|
+
"""
|
|
180
|
+
a, b = str(edge.get("source", "")), str(edge.get("target", ""))
|
|
181
|
+
lo, hi = (a, b) if a <= b else (b, a)
|
|
182
|
+
return (lo, hi, str(edge.get("quantity", "")))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --------------------------------------------------------------------------------------------
|
|
186
|
+
# The default scorer: grounding, not truth
|
|
187
|
+
# --------------------------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# A proposer's `trigger` field is overloaded: sometimes it is a checkable claim about the quote,
|
|
191
|
+
# sometimes a descriptive note about why the edge fired. Only the first kind can be verified, and
|
|
192
|
+
# treating the second kind as verifiable fails every edge of that type — which is exactly what
|
|
193
|
+
# happened on the first real corpus, where 100% of abstentions blamed an unverifiable trigger.
|
|
194
|
+
#
|
|
195
|
+
# ph=5.5 -> checkable: the VALUE must appear in the quote.
|
|
196
|
+
# supports re: mAb-X -> checkable: the entity must appear in the quote.
|
|
197
|
+
# mentions mAb-X -> checkable: likewise.
|
|
198
|
+
# decision on ph -> a note. Not checkable, and not evidence of anything wrong.
|
|
199
|
+
# protocol reference -> a note.
|
|
200
|
+
#
|
|
201
|
+
# Note that only the value is required, never the label: labels are normalised (`concentration_molar`)
|
|
202
|
+
# and by design do not appear in prose.
|
|
203
|
+
_TRIGGER_MEASUREMENT = re.compile(r"^[\w .+/-]+=(?P<value>.+)$")
|
|
204
|
+
_TRIGGER_ENTITY = re.compile(r"(?:\bre:|^mentions)\s+(?P<entity>.+)$", re.IGNORECASE)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _checkable_terms(trigger: str) -> list[str] | None:
|
|
208
|
+
"""What must appear verbatim in the quote for this trigger, or None if it is a note."""
|
|
209
|
+
trigger = (trigger or "").strip()
|
|
210
|
+
if not trigger:
|
|
211
|
+
return None
|
|
212
|
+
match = _TRIGGER_MEASUREMENT.match(trigger)
|
|
213
|
+
if match:
|
|
214
|
+
return [match.group("value").strip()]
|
|
215
|
+
match = _TRIGGER_ENTITY.search(trigger)
|
|
216
|
+
if match:
|
|
217
|
+
return [match.group("entity").strip()]
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class GroundingScorer:
|
|
222
|
+
"""Deterministic scorer: is this edge grounded in the text it cites?
|
|
223
|
+
|
|
224
|
+
Runs no model and needs no key. It checks what can be checked by reading the evidence against
|
|
225
|
+
the claim, and it is deliberately clear about what that does and does not establish — see the
|
|
226
|
+
module docstring. Each check contributes to a 0-10 score:
|
|
227
|
+
|
|
228
|
+
has_evidence the edge cites at least one passage at all (hard gate)
|
|
229
|
+
evidence_complete every cited passage carries a quote and a locator
|
|
230
|
+
trigger_verbatim the value the proposer says fired is actually in the quote (only
|
|
231
|
+
checked when the trigger makes a checkable claim — see `_checkable_terms`)
|
|
232
|
+
both_endpoints a COMPARING edge (supports/contradicts) cites evidence from both
|
|
233
|
+
documents; not applied to one-sided types, see TWO_SIDED_TYPES
|
|
234
|
+
type_cue_present the quote shows the cue the edge type implies (decided, uses-protocol)
|
|
235
|
+
|
|
236
|
+
`trigger_verbatim` is the one that catches real defects: a proposer that reports `ph=5.5` from a
|
|
237
|
+
passage that does not contain 5.5 has mis-parsed, and the resulting edge is unciteable.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
#: Weight per check. `has_evidence` is a gate rather than a weight — without it the score is 0.
|
|
241
|
+
WEIGHTS = {
|
|
242
|
+
"evidence_complete": 2.0,
|
|
243
|
+
"trigger_verbatim": 4.0,
|
|
244
|
+
"both_endpoints": 2.5,
|
|
245
|
+
"type_cue_present": 1.5,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
def __call__(self, edge: dict[str, Any]) -> EdgeScore:
|
|
249
|
+
evidence = edge.get("evidence") or []
|
|
250
|
+
if not evidence:
|
|
251
|
+
return EdgeScore(0.0, "no evidence cited", {"has_evidence": False})
|
|
252
|
+
|
|
253
|
+
checks: dict[str, bool] = {"has_evidence": True}
|
|
254
|
+
reasons: list[str] = []
|
|
255
|
+
|
|
256
|
+
checks["evidence_complete"] = all(
|
|
257
|
+
(ev.get("quote") or "").strip() and (ev.get("chunk_id") or ev.get("source_uri"))
|
|
258
|
+
for ev in evidence
|
|
259
|
+
)
|
|
260
|
+
if not checks["evidence_complete"]:
|
|
261
|
+
reasons.append("a cited passage is missing its quote or locator")
|
|
262
|
+
|
|
263
|
+
def grounded(ev: dict[str, Any]) -> bool:
|
|
264
|
+
terms = _checkable_terms(ev.get("trigger", ""))
|
|
265
|
+
quote = (ev.get("quote") or "").lower()
|
|
266
|
+
return all(term.lower() in quote for term in terms) if terms else True
|
|
267
|
+
|
|
268
|
+
checkable = [ev for ev in evidence if _checkable_terms(ev.get("trigger", "")) is not None]
|
|
269
|
+
if checkable:
|
|
270
|
+
checks["trigger_verbatim"] = all(grounded(ev) for ev in checkable)
|
|
271
|
+
if not checks["trigger_verbatim"]:
|
|
272
|
+
bad = next(ev for ev in checkable if not grounded(ev))
|
|
273
|
+
reasons.append(f"quote does not contain the trigger {bad['trigger']!r}")
|
|
274
|
+
# When no trigger makes a checkable claim there is nothing to verify. The check is left out
|
|
275
|
+
# of `checks` entirely, so it neither earns credit nor costs any — an edge is not penalised
|
|
276
|
+
# for a proposer that reported a note instead of a value.
|
|
277
|
+
|
|
278
|
+
if str(edge.get("type", "")) in TWO_SIDED_TYPES:
|
|
279
|
+
endpoints = {str(edge.get("source", "")), str(edge.get("target", ""))}
|
|
280
|
+
cited = {str(ev.get("artifact_id", "")) for ev in evidence}
|
|
281
|
+
checks["both_endpoints"] = endpoints.issubset(cited)
|
|
282
|
+
if not checks["both_endpoints"]:
|
|
283
|
+
missing = sorted(endpoints - cited)
|
|
284
|
+
reasons.append(f"no evidence cited from {', '.join(missing)}")
|
|
285
|
+
|
|
286
|
+
cue = _TYPE_CUES.get(str(edge.get("type", "")))
|
|
287
|
+
if cue is not None:
|
|
288
|
+
checks["type_cue_present"] = any(cue.search(ev.get("quote") or "") for ev in evidence)
|
|
289
|
+
if not checks["type_cue_present"]:
|
|
290
|
+
reasons.append(f"no {edge['type']} cue in any cited quote")
|
|
291
|
+
|
|
292
|
+
earned = sum(self.WEIGHTS[name] for name, ok in checks.items() if ok and name in self.WEIGHTS)
|
|
293
|
+
available = sum(self.WEIGHTS[name] for name in checks if name in self.WEIGHTS)
|
|
294
|
+
score = round(10.0 * earned / available, 2) if available else 0.0
|
|
295
|
+
|
|
296
|
+
rationale = "; ".join(reasons) if reasons else "evidence carries the claim verbatim"
|
|
297
|
+
return EdgeScore(score, rationale, checks)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
#: The library default. Keyless, deterministic, and honest about scoring grounding rather than truth.
|
|
301
|
+
DEFAULT_SCORER: EdgeScorer = GroundingScorer()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# --------------------------------------------------------------------------------------------
|
|
305
|
+
# Adjudication
|
|
306
|
+
# --------------------------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def adjudicate(
|
|
310
|
+
edges: list[dict[str, Any]],
|
|
311
|
+
*,
|
|
312
|
+
scorer: EdgeScorer | None = None,
|
|
313
|
+
assert_at: float = DEFAULT_ASSERT_AT,
|
|
314
|
+
) -> AdjudicationResult:
|
|
315
|
+
"""Score every candidate edge and return a verdict for each.
|
|
316
|
+
|
|
317
|
+
Order of resolution matters. Conflicts are found FIRST, across the whole candidate set, because
|
|
318
|
+
a conflict is a property of the set rather than of any single edge: two edges that make
|
|
319
|
+
incompatible claims about the same document pair are both kept and marked, instead of one of
|
|
320
|
+
them winning on score. Only the remaining edges face the `assert_at` threshold.
|
|
321
|
+
|
|
322
|
+
Deterministic given a deterministic scorer: candidates keep their input order, and nothing here
|
|
323
|
+
depends on iteration order of a set.
|
|
324
|
+
"""
|
|
325
|
+
scorer = scorer or DEFAULT_SCORER
|
|
326
|
+
scored = [(edge, scorer(edge)) for edge in edges]
|
|
327
|
+
|
|
328
|
+
# Which claims have incompatible candidates? Group by the claim, not the edge.
|
|
329
|
+
by_claim: dict[tuple[str, str, str], set[str]] = {}
|
|
330
|
+
for edge, _ in scored:
|
|
331
|
+
etype = str(edge.get("type", ""))
|
|
332
|
+
if etype in INCOMPATIBLE_TYPES:
|
|
333
|
+
by_claim.setdefault(_pair_key(edge), set()).add(etype)
|
|
334
|
+
contested = {claim for claim, types in by_claim.items() if len(types & INCOMPATIBLE_TYPES) > 1}
|
|
335
|
+
|
|
336
|
+
out: list[Adjudication] = []
|
|
337
|
+
for edge, result in scored:
|
|
338
|
+
claim = _pair_key(edge)
|
|
339
|
+
if claim in contested and str(edge.get("type", "")) in INCOMPATIBLE_TYPES:
|
|
340
|
+
others = tuple(
|
|
341
|
+
sorted(
|
|
342
|
+
edge_key(other)
|
|
343
|
+
for other, _ in scored
|
|
344
|
+
if _pair_key(other) == claim
|
|
345
|
+
and str(other.get("type", "")) in INCOMPATIBLE_TYPES
|
|
346
|
+
and edge_key(other) != edge_key(edge)
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
out.append(
|
|
350
|
+
Adjudication(
|
|
351
|
+
edge=edge,
|
|
352
|
+
verdict=Verdict.CONFLICT,
|
|
353
|
+
score=result.score,
|
|
354
|
+
rationale=(
|
|
355
|
+
"the candidate set disagrees about this pair: "
|
|
356
|
+
+ " and ".join(sorted(by_claim[claim]))
|
|
357
|
+
),
|
|
358
|
+
checks=result.checks,
|
|
359
|
+
conflicts_with=others,
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
verdict = Verdict.ASSERT if result.score >= assert_at else Verdict.ABSTAIN
|
|
365
|
+
rationale = result.rationale
|
|
366
|
+
if verdict is Verdict.ABSTAIN and not rationale.startswith("no evidence"):
|
|
367
|
+
rationale = f"{rationale} (score {result.score} below {assert_at})"
|
|
368
|
+
out.append(
|
|
369
|
+
Adjudication(
|
|
370
|
+
edge=edge,
|
|
371
|
+
verdict=verdict,
|
|
372
|
+
score=result.score,
|
|
373
|
+
rationale=rationale,
|
|
374
|
+
checks=result.checks,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
return AdjudicationResult(tuple(out))
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def open_questions(result: AdjudicationResult) -> list[dict[str, Any]]:
|
|
382
|
+
"""Render the abstentions as a curator's review queue.
|
|
383
|
+
|
|
384
|
+
Each entry carries the claim, why the adjudicator was not convinced, and the evidence it looked
|
|
385
|
+
at, so a human can settle it without going back to the corpus themselves.
|
|
386
|
+
"""
|
|
387
|
+
return [
|
|
388
|
+
{
|
|
389
|
+
"key": a.key,
|
|
390
|
+
"source": a.edge.get("source"),
|
|
391
|
+
"target": a.edge.get("target"),
|
|
392
|
+
"type": a.edge.get("type"),
|
|
393
|
+
"quantity": a.edge.get("quantity"),
|
|
394
|
+
"score": a.score,
|
|
395
|
+
"why_not_asserted": a.rationale,
|
|
396
|
+
"evidence": a.edge.get("evidence") or [],
|
|
397
|
+
}
|
|
398
|
+
for a in result.abstained
|
|
399
|
+
]
|