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.
@@ -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
+ ]