seqforge 2026.7.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.
- seqforge/__init__.py +16 -0
- seqforge/cli/__init__.py +38 -0
- seqforge/cli/__main__.py +8 -0
- seqforge/cli/_common.py +105 -0
- seqforge/cli/compose.py +119 -0
- seqforge/cli/eval.py +103 -0
- seqforge/cli/harvest.py +417 -0
- seqforge/cli/hook.py +247 -0
- seqforge/cli/io.py +502 -0
- seqforge/cli/kb.py +348 -0
- seqforge/cli/manifest.py +536 -0
- seqforge/cli/probe.py +43 -0
- seqforge/cli/processing.py +192 -0
- seqforge/cli/project.py +52 -0
- seqforge/cli/resolve.py +55 -0
- seqforge/cli/root.py +66 -0
- seqforge/cli/run.py +463 -0
- seqforge/cli/schema.py +41 -0
- seqforge/compose/__init__.py +28 -0
- seqforge/compose/core.py +515 -0
- seqforge/compose/gates.py +113 -0
- seqforge/compose/params.py +447 -0
- seqforge/e2e.py +1926 -0
- seqforge/evals/__init__.py +78 -0
- seqforge/evals/case.py +382 -0
- seqforge/evals/grade.py +300 -0
- seqforge/evals/run.py +420 -0
- seqforge/harvest/__init__.py +121 -0
- seqforge/harvest/extract.py +319 -0
- seqforge/harvest/fields.py +212 -0
- seqforge/harvest/normalize.py +537 -0
- seqforge/harvest/prep.py +41 -0
- seqforge/harvest/providers.py +321 -0
- seqforge/harvest/verify.py +251 -0
- seqforge/hooks/__init__.py +33 -0
- seqforge/hooks/guards.py +214 -0
- seqforge/io/__init__.py +61 -0
- seqforge/io/archive.py +450 -0
- seqforge/io/attributes.py +190 -0
- seqforge/io/biosample/attributes.json +6341 -0
- seqforge/io/efo/labels.json +55 -0
- seqforge/io/efo.py +138 -0
- seqforge/io/onlist.py +661 -0
- seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
- seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
- seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
- seqforge/io/onlists/index.json +74 -0
- seqforge/io/remote.py +659 -0
- seqforge/io/taxonomy.py +194 -0
- seqforge/kb/__init__.py +62 -0
- seqforge/kb/anchor.py +169 -0
- seqforge/kb/generate.py +147 -0
- seqforge/kb/loader.py +152 -0
- seqforge/kb/roundtrip.py +112 -0
- seqforge/kb/schema.py +422 -0
- seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
- seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
- seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
- seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
- seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
- seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
- seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
- seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
- seqforge/kb/specs/splitseq/README.md +51 -0
- seqforge/kb/specs/splitseq/spec.yaml +157 -0
- seqforge/manifest/__init__.py +61 -0
- seqforge/manifest/fill.py +531 -0
- seqforge/manifest/hash.py +77 -0
- seqforge/manifest/instruct.py +114 -0
- seqforge/manifest/policy.py +409 -0
- seqforge/manifest/validate.py +274 -0
- seqforge/models/__init__.py +268 -0
- seqforge/models/assertion.py +68 -0
- seqforge/models/base.py +100 -0
- seqforge/models/blocker.py +71 -0
- seqforge/models/conflict.py +47 -0
- seqforge/models/dataset.py +320 -0
- seqforge/models/evidenced.py +54 -0
- seqforge/models/observation.py +157 -0
- seqforge/models/processing.py +231 -0
- seqforge/models/records.py +145 -0
- seqforge/models/resolve.py +216 -0
- seqforge/probe/__init__.py +46 -0
- seqforge/probe/core.py +232 -0
- seqforge/probe/signals.py +250 -0
- seqforge/probe/streaming.py +118 -0
- seqforge/project.py +177 -0
- seqforge/py.typed +0 -0
- seqforge/resolve/__init__.py +98 -0
- seqforge/resolve/assign.py +204 -0
- seqforge/resolve/cache.py +119 -0
- seqforge/resolve/confuse.py +215 -0
- seqforge/resolve/engine.py +646 -0
- seqforge/resolve/escalate.py +668 -0
- seqforge/resolve/evaluators.py +306 -0
- seqforge/resolve/geometry.py +89 -0
- seqforge/resolve/group.py +85 -0
- seqforge/resolve/records.py +550 -0
- seqforge/resolve/scoring.py +373 -0
- seqforge/resolve/window.py +206 -0
- seqforge/workflows/__init__.py +234 -0
- seqforge/workflows/cram.py +117 -0
- seqforge/workflows/h5ad.py +368 -0
- seqforge/workflows/map/star.smk +101 -0
- seqforge/workflows/map/starsolo.smk +360 -0
- seqforge/workflows/qc.py +157 -0
- seqforge/workspace.py +125 -0
- seqforge-2026.7.1.dist-info/METADATA +125 -0
- seqforge-2026.7.1.dist-info/RECORD +124 -0
- seqforge-2026.7.1.dist-info/WHEEL +4 -0
- seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
- seqforge-2026.7.1.dist-info/licenses/LICENSE +21 -0
seqforge/evals/run.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""The eval runner — drive real cases through the real compiler and score the outcome (brief §9).
|
|
2
|
+
|
|
3
|
+
This runs the shipping code path, not a reimplementation of it: ``materialize -> [harvest] -> resolve``
|
|
4
|
+
via the same ``resolve_dataset`` / ``extract_drafts`` / ``verify_drafts`` the CLI calls. An eval that
|
|
5
|
+
tested a parallel copy of the pipeline would grade the wrong program.
|
|
6
|
+
|
|
7
|
+
Two design points carry most of the value:
|
|
8
|
+
|
|
9
|
+
**Trials are first-class.** The LLM stage is nondeterministic — the same document has been observed to
|
|
10
|
+
yield different (both valid) quotes across runs. A single trial therefore measures a sample, not the
|
|
11
|
+
system. ``--trials N`` re-runs each prose case and reports ``stability``; a case counts as correct only
|
|
12
|
+
if **every** trial was correct. A stage that is right 4 times in 5 is not right, and averaging that
|
|
13
|
+
away is how a harness lies to you.
|
|
14
|
+
|
|
15
|
+
**Harvest false-accepts roll up into the case grade.** A verified-but-wrong ``experiment.*`` assertion
|
|
16
|
+
is not a lesser failure than a wrong chemistry: bytes can never contradict it, so it reaches the
|
|
17
|
+
manifest unchallenged. It grades ``false_accept`` like any other silent wrong answer.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass, field, replace
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..harvest import (
|
|
29
|
+
ExtractUnavailable,
|
|
30
|
+
LLMProvider,
|
|
31
|
+
extract_drafts,
|
|
32
|
+
has_prose,
|
|
33
|
+
normalize_document,
|
|
34
|
+
normalize_record,
|
|
35
|
+
resolve_provider,
|
|
36
|
+
verify_drafts,
|
|
37
|
+
)
|
|
38
|
+
from ..harvest.fields import fields_for
|
|
39
|
+
from ..harvest.normalize import NormalizedDoc
|
|
40
|
+
from ..kb.loader import load_all_specs
|
|
41
|
+
from ..models.assertion import Assertion, AssertionDraft, ExtractorProvenance
|
|
42
|
+
from ..models.resolve import EvalReport
|
|
43
|
+
from ..resolve import Hypothesis, resolve_dataset
|
|
44
|
+
from ..resolve.records import DocumentSubject, resolve_metadata
|
|
45
|
+
from .case import Case, CaseError, CaseSkipped, Materialized, discover_cases, materialize
|
|
46
|
+
from .grade import CaseGrade, Grade, grade_case
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class HarvestGrade:
|
|
51
|
+
"""How the LLM stage did on one case: recall, hallucination, and what the tripwire caught."""
|
|
52
|
+
|
|
53
|
+
matched: list[str] = field(default_factory=list)
|
|
54
|
+
missing: list[str] = field(default_factory=list)
|
|
55
|
+
#: Forbidden fields that survived verification — a claim the prose does not make. Corpus poison.
|
|
56
|
+
hallucinated: list[str] = field(default_factory=list)
|
|
57
|
+
#: Drafts the span-verification tripwire rejected. Not a failure: this is the safety net doing its job.
|
|
58
|
+
n_rejected: int = 0
|
|
59
|
+
#: Fields extracted in SOME trials but not all. Not averaged away: a field the model finds two
|
|
60
|
+
#: times in three is a field you cannot depend on, and that is a finding in its own right.
|
|
61
|
+
unstable: list[str] = field(default_factory=list)
|
|
62
|
+
extracted: dict[str, str] = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
def to_json(self) -> dict[str, Any]:
|
|
65
|
+
out = {
|
|
66
|
+
"matched": self.matched,
|
|
67
|
+
"missing": self.missing,
|
|
68
|
+
"hallucinated": self.hallucinated,
|
|
69
|
+
"n_rejected": self.n_rejected,
|
|
70
|
+
"extracted": self.extracted,
|
|
71
|
+
}
|
|
72
|
+
if self.unstable:
|
|
73
|
+
out["unstable"] = self.unstable
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class CaseRun:
|
|
79
|
+
"""One case's full result across all trials."""
|
|
80
|
+
|
|
81
|
+
case_id: str
|
|
82
|
+
grade: CaseGrade
|
|
83
|
+
trials: int = 1
|
|
84
|
+
stability: float = 1.0
|
|
85
|
+
harvest: HarvestGrade | None = None
|
|
86
|
+
usage: dict[str, int] = field(default_factory=dict)
|
|
87
|
+
seconds: float = 0.0
|
|
88
|
+
llm_calls: int = 0
|
|
89
|
+
skipped: str | None = None
|
|
90
|
+
|
|
91
|
+
def to_json(self) -> dict[str, Any]:
|
|
92
|
+
out: dict[str, Any] = {
|
|
93
|
+
"case": self.case_id,
|
|
94
|
+
"seconds": round(self.seconds, 3),
|
|
95
|
+
"llm_calls": self.llm_calls,
|
|
96
|
+
}
|
|
97
|
+
if self.skipped:
|
|
98
|
+
return {**out, "skipped": self.skipped}
|
|
99
|
+
out.update(self.grade.to_json())
|
|
100
|
+
out["trials"] = self.trials
|
|
101
|
+
out["stability"] = round(self.stability, 3)
|
|
102
|
+
if self.usage:
|
|
103
|
+
out["usage"] = self.usage
|
|
104
|
+
if self.harvest is not None:
|
|
105
|
+
out["harvest"] = self.harvest.to_json()
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run_case(
|
|
110
|
+
case: Case,
|
|
111
|
+
*,
|
|
112
|
+
llm: bool = False,
|
|
113
|
+
provider: LLMProvider | None = None,
|
|
114
|
+
model: str | None = None,
|
|
115
|
+
trials: int = 1,
|
|
116
|
+
workspace: Path | None = None,
|
|
117
|
+
) -> CaseRun:
|
|
118
|
+
"""Run one case through the compiler ``trials`` times and grade every trial.
|
|
119
|
+
|
|
120
|
+
Deterministic cases ignore ``trials`` (re-running identical bytes measures nothing).
|
|
121
|
+
"""
|
|
122
|
+
started = time.monotonic()
|
|
123
|
+
needs_llm = case.has_prose and case.recipe.hypothesis is None
|
|
124
|
+
if needs_llm and not llm:
|
|
125
|
+
# The case's expectation *depends* on a claim only the LLM can supply. Running it byte-only
|
|
126
|
+
# would grade a different question and count the miss as a failure. Skip, loudly.
|
|
127
|
+
return CaseRun(
|
|
128
|
+
case.id,
|
|
129
|
+
_empty_grade(case),
|
|
130
|
+
skipped="needs the LLM stage (prose, no declared hypothesis); pass --llm",
|
|
131
|
+
seconds=time.monotonic() - started,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
use_llm = llm and case.has_prose
|
|
135
|
+
n = trials if use_llm else 1
|
|
136
|
+
|
|
137
|
+
grades: list[CaseGrade] = []
|
|
138
|
+
harvests: list[HarvestGrade] = []
|
|
139
|
+
usage: dict[str, int] = {}
|
|
140
|
+
calls = 0
|
|
141
|
+
|
|
142
|
+
with tempfile.TemporaryDirectory(prefix="seqforge-eval-") as tmp:
|
|
143
|
+
tmp_path = Path(tmp)
|
|
144
|
+
try:
|
|
145
|
+
built = materialize(case, tmp_path / "inputs")
|
|
146
|
+
except CaseSkipped as exc:
|
|
147
|
+
return CaseRun(
|
|
148
|
+
case.id, _empty_grade(case), skipped=str(exc), seconds=time.monotonic() - started
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
ws = workspace or tmp_path
|
|
152
|
+
for _ in range(n):
|
|
153
|
+
hypothesis: Hypothesis | None = None
|
|
154
|
+
if case.recipe.hypothesis:
|
|
155
|
+
hypothesis = Hypothesis(value=case.recipe.hypothesis, id="recipe", confidence=0.9)
|
|
156
|
+
verified: list[Assertion] = []
|
|
157
|
+
subjects: list[DocumentSubject] = []
|
|
158
|
+
if use_llm:
|
|
159
|
+
try:
|
|
160
|
+
hg, hyp, u, verified, subjects = _run_harvest(
|
|
161
|
+
case, provider=provider, model=model
|
|
162
|
+
)
|
|
163
|
+
except ExtractUnavailable as exc:
|
|
164
|
+
return CaseRun(
|
|
165
|
+
case.id,
|
|
166
|
+
_empty_grade(case),
|
|
167
|
+
skipped=f"LLM unavailable: {exc}",
|
|
168
|
+
seconds=time.monotonic() - started,
|
|
169
|
+
)
|
|
170
|
+
harvests.append(hg)
|
|
171
|
+
calls += len(case.metadata_docs)
|
|
172
|
+
for k, v in u.items():
|
|
173
|
+
usage[k] = usage.get(k, 0) + v
|
|
174
|
+
if hyp is not None:
|
|
175
|
+
hypothesis = hyp
|
|
176
|
+
|
|
177
|
+
out = resolve_dataset(
|
|
178
|
+
built.paths,
|
|
179
|
+
registry=built.registry,
|
|
180
|
+
hypothesis=hypothesis,
|
|
181
|
+
workspace=ws,
|
|
182
|
+
use_cache=False,
|
|
183
|
+
)
|
|
184
|
+
# The SECOND resolver, over the same files. It reads records and prose; it is handed no
|
|
185
|
+
# probe signal (`FileIdentity`, not `Observation`). Running it here is what lets a
|
|
186
|
+
# pre-registration's sample claims be graded at all -- before this the harness could not
|
|
187
|
+
# see a sample, so "tissue=Neurons" was prose in a description field.
|
|
188
|
+
metadata = resolve_metadata(
|
|
189
|
+
files=[o.file for o in out.observations],
|
|
190
|
+
records=built.records,
|
|
191
|
+
assertions=verified,
|
|
192
|
+
subjects=subjects,
|
|
193
|
+
)
|
|
194
|
+
trial_grade = grade_case(
|
|
195
|
+
case.id,
|
|
196
|
+
case.expected,
|
|
197
|
+
out.result,
|
|
198
|
+
out.exit_code(),
|
|
199
|
+
_labels(out, built),
|
|
200
|
+
metadata,
|
|
201
|
+
)
|
|
202
|
+
# Fold THIS trial's harvest into THIS trial's grade. Folding once at the end against the
|
|
203
|
+
# merged harvest would charge every trial for one trial's hallucination, and `stability`
|
|
204
|
+
# would stop meaning "how often was the whole case right".
|
|
205
|
+
if harvests:
|
|
206
|
+
trial_grade = _fold_harvest(trial_grade, harvests[-1])
|
|
207
|
+
grades.append(trial_grade)
|
|
208
|
+
|
|
209
|
+
harvest = _merge_harvest(harvests) if harvests else None
|
|
210
|
+
worst = _worst(grades)
|
|
211
|
+
n_ok = sum(1 for g in grades if g.ok)
|
|
212
|
+
return CaseRun(
|
|
213
|
+
case_id=case.id,
|
|
214
|
+
grade=worst,
|
|
215
|
+
trials=n,
|
|
216
|
+
stability=n_ok / len(grades) if grades else 0.0,
|
|
217
|
+
harvest=harvest,
|
|
218
|
+
usage=usage,
|
|
219
|
+
seconds=time.monotonic() - started,
|
|
220
|
+
llm_calls=calls,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def run_cases(
|
|
225
|
+
cases: list[Case],
|
|
226
|
+
*,
|
|
227
|
+
llm: bool = False,
|
|
228
|
+
provider: LLMProvider | None = None,
|
|
229
|
+
model: str | None = None,
|
|
230
|
+
trials: int = 1,
|
|
231
|
+
) -> tuple[EvalReport, list[CaseRun]]:
|
|
232
|
+
"""Run every case and aggregate into the brief §9 metric set."""
|
|
233
|
+
runs = [run_case(c, llm=llm, provider=provider, model=model, trials=trials) for c in cases]
|
|
234
|
+
return build_report(runs), runs
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def build_report(runs: list[CaseRun]) -> EvalReport:
|
|
238
|
+
"""Aggregate. Skipped cases are excluded from every rate — a skip is not a pass."""
|
|
239
|
+
scored = [r for r in runs if r.skipped is None]
|
|
240
|
+
n = len(scored)
|
|
241
|
+
|
|
242
|
+
checks = [c for r in scored for c in r.grade.fields]
|
|
243
|
+
n_field = len(checks) + sum(
|
|
244
|
+
len(r.harvest.matched) + len(r.harvest.missing) + len(r.harvest.hallucinated)
|
|
245
|
+
for r in scored
|
|
246
|
+
if r.harvest is not None
|
|
247
|
+
)
|
|
248
|
+
n_field_ok = sum(1 for c in checks if c.ok) + sum(
|
|
249
|
+
len(r.harvest.matched) for r in scored if r.harvest is not None
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
false_accept = sum(1 for r in scored if r.grade.grade is Grade.FALSE_ACCEPT)
|
|
253
|
+
false_refuse = sum(1 for r in scored if r.grade.grade is Grade.FALSE_REFUSE)
|
|
254
|
+
asked = sum(1 for r in scored if r.grade.actual_outcome == "ask")
|
|
255
|
+
missed = sum(1 for r in scored if r.grade.missed_question)
|
|
256
|
+
|
|
257
|
+
cost: dict[str, float] = {
|
|
258
|
+
"seconds": round(sum(r.seconds for r in runs), 3),
|
|
259
|
+
"llm_calls": float(sum(r.llm_calls for r in runs)),
|
|
260
|
+
}
|
|
261
|
+
for key in ("input_tokens", "output_tokens", "cache_read_tokens"):
|
|
262
|
+
total = sum(r.usage.get(key, 0) for r in runs)
|
|
263
|
+
if total:
|
|
264
|
+
cost[key] = float(total)
|
|
265
|
+
|
|
266
|
+
return EvalReport(
|
|
267
|
+
n_cases=n,
|
|
268
|
+
field_accuracy=(n_field_ok / n_field) if n_field else 1.0,
|
|
269
|
+
false_accept_rate=(false_accept / n) if n else 0.0,
|
|
270
|
+
false_refuse_rate=(false_refuse / n) if n else 0.0,
|
|
271
|
+
questions_asked={
|
|
272
|
+
"total": float(asked),
|
|
273
|
+
"per_case": (asked / n) if n else 0.0,
|
|
274
|
+
"missed": float(missed),
|
|
275
|
+
},
|
|
276
|
+
cost=cost,
|
|
277
|
+
per_case=[r.to_json() for r in runs],
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def load_cases(cases_dir: Path | None = None, *, only: list[str] | None = None) -> list[Case]:
|
|
282
|
+
cases = discover_cases(cases_dir)
|
|
283
|
+
if only:
|
|
284
|
+
wanted = set(only)
|
|
285
|
+
cases = [c for c in cases if c.id in wanted]
|
|
286
|
+
missing = wanted - {c.id for c in cases}
|
|
287
|
+
if missing:
|
|
288
|
+
raise CaseError(f"no such case(s): {sorted(missing)}")
|
|
289
|
+
return cases
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _run_harvest(
|
|
293
|
+
case: Case, *, provider: LLMProvider | None, model: str | None
|
|
294
|
+
) -> tuple[HarvestGrade, Hypothesis | None, dict[str, int], list[Assertion], list[DocumentSubject]]:
|
|
295
|
+
"""normalize -> extract -> verify over the case's prose. Only verified claims are graded.
|
|
296
|
+
|
|
297
|
+
"The case's prose" now means two things: the documents a human put beside it, and each archive
|
|
298
|
+
record rendered as its own document. The second is what lets a claim name a sample, so a harness
|
|
299
|
+
that ran only the first could never grade one.
|
|
300
|
+
"""
|
|
301
|
+
specs = load_all_specs()
|
|
302
|
+
llm = provider if provider is not None else resolve_provider()
|
|
303
|
+
|
|
304
|
+
docs: list[NormalizedDoc] = [normalize_document(p) for p in case.metadata_docs]
|
|
305
|
+
if case.records is not None:
|
|
306
|
+
docs += [
|
|
307
|
+
normalize_record(r)
|
|
308
|
+
for r in case.records.records
|
|
309
|
+
if has_prose(r) and fields_for(r.level, "reference")
|
|
310
|
+
]
|
|
311
|
+
drafts: list[AssertionDraft] = []
|
|
312
|
+
usage: dict[str, int] = {}
|
|
313
|
+
extractor: ExtractorProvenance | None = None
|
|
314
|
+
for doc in docs:
|
|
315
|
+
outcome = extract_drafts(doc, specs, provider=llm, model=model)
|
|
316
|
+
drafts.extend(outcome.drafts)
|
|
317
|
+
extractor = outcome.extractor
|
|
318
|
+
for k, v in outcome.usage.items():
|
|
319
|
+
usage[k] = usage.get(k, 0) + v
|
|
320
|
+
|
|
321
|
+
assert extractor is not None # docs is non-empty (checked by the caller via has_prose)
|
|
322
|
+
report = verify_drafts(drafts, docs, extractor=extractor)
|
|
323
|
+
accepted: list[Assertion] = report.assertions
|
|
324
|
+
by_field = {a.field: a for a in accepted}
|
|
325
|
+
|
|
326
|
+
grade = HarvestGrade(n_rejected=len(report.rejected))
|
|
327
|
+
grade.extracted = {a.field: str(a.value) for a in accepted}
|
|
328
|
+
for want in case.expected.assertions:
|
|
329
|
+
got = by_field.get(want.field)
|
|
330
|
+
if got is not None and str(got.value) == want.value:
|
|
331
|
+
grade.matched.append(want.field)
|
|
332
|
+
else:
|
|
333
|
+
grade.missing.append(want.field)
|
|
334
|
+
grade.hallucinated = [f for f in case.expected.forbidden_fields if f in by_field]
|
|
335
|
+
|
|
336
|
+
hypothesis: Hypothesis | None = None
|
|
337
|
+
chem = by_field.get("library.chemistry")
|
|
338
|
+
if chem is not None:
|
|
339
|
+
hypothesis = Hypothesis(value=str(chem.value), id="harvest", confidence=0.9)
|
|
340
|
+
subjects = [
|
|
341
|
+
DocumentSubject(doc_sha256=d.doc_sha256, scope=d.scope, subject=d.subject) for d in docs
|
|
342
|
+
]
|
|
343
|
+
return grade, hypothesis, usage, accepted, subjects
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _merge_harvest(grades: list[HarvestGrade]) -> HarvestGrade:
|
|
347
|
+
"""Across trials, keep the WORST — never the last.
|
|
348
|
+
|
|
349
|
+
Extraction is nondeterministic, so a field the model invented in 1 of 3 trials is a field it *can*
|
|
350
|
+
invent, and a field it extracted in only 2 of 3 is not one you can rely on. Reporting the final
|
|
351
|
+
trial (the bug this replaces) let a real hallucination vanish on a re-run — exactly the illusion
|
|
352
|
+
trials exist to dispel.
|
|
353
|
+
|
|
354
|
+
So: ``hallucinated`` and ``missing`` union (any trial failing is a failure), ``matched``
|
|
355
|
+
intersects (a field counts only if EVERY trial got it), and fields that come and go are surfaced
|
|
356
|
+
as ``unstable`` rather than silently averaged into a rate.
|
|
357
|
+
"""
|
|
358
|
+
if len(grades) == 1:
|
|
359
|
+
return grades[0]
|
|
360
|
+
merged = HarvestGrade()
|
|
361
|
+
merged.hallucinated = sorted({f for g in grades for f in g.hallucinated})
|
|
362
|
+
merged.missing = sorted({f for g in grades for f in g.missing})
|
|
363
|
+
merged.matched = sorted(set.intersection(*(set(g.matched) for g in grades)))
|
|
364
|
+
merged.n_rejected = sum(g.n_rejected for g in grades)
|
|
365
|
+
seen = {f for g in grades for f in g.matched}
|
|
366
|
+
merged.unstable = sorted(seen - set(merged.matched))
|
|
367
|
+
for g in grades:
|
|
368
|
+
merged.extracted.update(g.extracted)
|
|
369
|
+
return merged
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _fold_harvest(grade: CaseGrade, harvest: HarvestGrade) -> CaseGrade:
|
|
373
|
+
"""A verified-but-wrong assertion is a false-accept: bytes can never contradict ``experiment.*``.
|
|
374
|
+
|
|
375
|
+
Returns a NEW grade. It used to mutate in place, and because ``_worst`` hands back a reference
|
|
376
|
+
into the trials list rather than a copy, folding the worst grade rewrote a list element that
|
|
377
|
+
``stability`` was then counted over — reporting 0.667 for three identical, perfectly stable
|
|
378
|
+
trials. A metric that is quietly wrong is worse than no metric, so this no longer mutates at all.
|
|
379
|
+
"""
|
|
380
|
+
if harvest.hallucinated:
|
|
381
|
+
return replace(
|
|
382
|
+
grade,
|
|
383
|
+
grade=Grade.FALSE_ACCEPT,
|
|
384
|
+
notes=[
|
|
385
|
+
*grade.notes,
|
|
386
|
+
f"extracted claims the prose does not make: {harvest.hallucinated} "
|
|
387
|
+
"(verified, so nothing downstream would catch it)",
|
|
388
|
+
],
|
|
389
|
+
)
|
|
390
|
+
if harvest.missing and grade.grade is Grade.CORRECT:
|
|
391
|
+
return replace(
|
|
392
|
+
grade,
|
|
393
|
+
grade=Grade.WRONG_REASON,
|
|
394
|
+
notes=[*grade.notes, f"failed to extract stated field(s): {harvest.missing}"],
|
|
395
|
+
)
|
|
396
|
+
return grade
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _worst(grades: list[CaseGrade]) -> CaseGrade:
|
|
400
|
+
"""Across trials, report the worst outcome — a stage that fails sometimes fails."""
|
|
401
|
+
order = {
|
|
402
|
+
Grade.FALSE_ACCEPT: 0,
|
|
403
|
+
Grade.FALSE_REFUSE: 1,
|
|
404
|
+
Grade.MIS_TRIAGE: 2,
|
|
405
|
+
Grade.WRONG_REASON: 3,
|
|
406
|
+
Grade.OVER_ASK: 4,
|
|
407
|
+
Grade.CORRECT: 5,
|
|
408
|
+
}
|
|
409
|
+
return min(grades, key=lambda g: order[g.grade])
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _labels(out: Any, built: Materialized) -> dict[str, str]:
|
|
413
|
+
"""sha256 -> recipe read id, so role assertions are written against ``R1``/``R2``, not hashes."""
|
|
414
|
+
return {
|
|
415
|
+
o.file.sha256: built.labels.get(o.file.basename, o.file.basename) for o in out.observations
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _empty_grade(case: Case) -> CaseGrade:
|
|
420
|
+
return CaseGrade(case.id, Grade.CORRECT, case.expected.outcome, "skipped")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""``harvest`` — prose/metadata -> span-verified :class:`Assertion`s (the ONE LLM touchpoint).
|
|
2
|
+
|
|
3
|
+
Three verbs, and only the middle one touches a model:
|
|
4
|
+
|
|
5
|
+
- ``normalize`` (deterministic) — source docs -> the canonical text that spans are computed against.
|
|
6
|
+
- ``extract`` (**LLM**) — normalized text (+ KB prose) -> ``AssertionDraft[]``. The model emits
|
|
7
|
+
``{field, value, quote}`` and nothing else: no offsets (it cannot count characters) and no verdicts.
|
|
8
|
+
- ``verify`` (deterministic) — checks the field is one we allow at all, greps each quote back into
|
|
9
|
+
the canonical text, computes the offsets, and checks the quote actually *entails* the value. Every
|
|
10
|
+
flag is code-owned, so a hallucinated or mis-attributed claim fails closed.
|
|
11
|
+
|
|
12
|
+
Agents propose; code decides. Nothing here trusts the model's own account of its work — including its
|
|
13
|
+
account of *which field it is answering*: the vocabulary lives in :mod:`seqforge.harvest.fields` and is
|
|
14
|
+
enforced there, not in the prompt. A prompt asks; only code refuses.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
#: CalVer YYYY.M.PATCH; bumped when harvest semantics change. Folded into artifact cache keys.
|
|
20
|
+
#: 2026.7.1: PDF extraction changed materially — pymupdf is the default engine, in-text tables are
|
|
21
|
+
#: spliced into the canonical text, and invalid unicode is scrubbed — so a cached harvest must re-run.
|
|
22
|
+
HARVEST_VERSION = "2026.7.1"
|
|
23
|
+
|
|
24
|
+
from .extract import ( # noqa: E402
|
|
25
|
+
EXTRACT_PROMPT_VERSION,
|
|
26
|
+
ExtractionOutcome,
|
|
27
|
+
ExtractionResult,
|
|
28
|
+
ExtractUnavailable,
|
|
29
|
+
build_kb_context,
|
|
30
|
+
build_system_prompt,
|
|
31
|
+
extract_drafts,
|
|
32
|
+
llm_schema,
|
|
33
|
+
)
|
|
34
|
+
from .fields import ( # noqa: E402
|
|
35
|
+
ASKED_SAMPLE_ATTRIBUTES,
|
|
36
|
+
DEFAULT_FIELDS,
|
|
37
|
+
PERMITTED_FIELDS,
|
|
38
|
+
DocScope,
|
|
39
|
+
is_permitted,
|
|
40
|
+
)
|
|
41
|
+
from .normalize import ( # noqa: E402
|
|
42
|
+
DEFAULT_PDF_BACKEND,
|
|
43
|
+
NORMALIZER_VERSION,
|
|
44
|
+
NormalizedDoc,
|
|
45
|
+
PageSpan,
|
|
46
|
+
PdfBackend,
|
|
47
|
+
UnreadableDocument,
|
|
48
|
+
clean_invalid_unicode,
|
|
49
|
+
has_prose,
|
|
50
|
+
normalize_document,
|
|
51
|
+
normalize_record,
|
|
52
|
+
normalize_text,
|
|
53
|
+
page_for_offset,
|
|
54
|
+
read_document,
|
|
55
|
+
render_record,
|
|
56
|
+
)
|
|
57
|
+
from .providers import ( # noqa: E402
|
|
58
|
+
ANTHROPIC_DEFAULT_MODEL,
|
|
59
|
+
DEEPSEEK_DEFAULT_MODEL,
|
|
60
|
+
AnthropicProvider,
|
|
61
|
+
LLMProvider,
|
|
62
|
+
LLMResponse,
|
|
63
|
+
OpenAICompatibleProvider,
|
|
64
|
+
ProviderUnavailable,
|
|
65
|
+
deepseek_provider,
|
|
66
|
+
resolve_provider,
|
|
67
|
+
)
|
|
68
|
+
from .verify import ( # noqa: E402
|
|
69
|
+
VerifyReport,
|
|
70
|
+
entails,
|
|
71
|
+
find_span,
|
|
72
|
+
surface_forms,
|
|
73
|
+
verify_drafts,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"HARVEST_VERSION",
|
|
78
|
+
"NORMALIZER_VERSION",
|
|
79
|
+
"DEFAULT_PDF_BACKEND",
|
|
80
|
+
"PdfBackend",
|
|
81
|
+
"NormalizedDoc",
|
|
82
|
+
"PageSpan",
|
|
83
|
+
"UnreadableDocument",
|
|
84
|
+
"normalize_document",
|
|
85
|
+
"normalize_record",
|
|
86
|
+
"render_record",
|
|
87
|
+
"has_prose",
|
|
88
|
+
"normalize_text",
|
|
89
|
+
"clean_invalid_unicode",
|
|
90
|
+
"page_for_offset",
|
|
91
|
+
"read_document",
|
|
92
|
+
"VerifyReport",
|
|
93
|
+
"verify_drafts",
|
|
94
|
+
"find_span",
|
|
95
|
+
"entails",
|
|
96
|
+
"surface_forms",
|
|
97
|
+
# extract (the one LLM touchpoint)
|
|
98
|
+
"EXTRACT_PROMPT_VERSION",
|
|
99
|
+
"DEFAULT_FIELDS",
|
|
100
|
+
"PERMITTED_FIELDS",
|
|
101
|
+
"is_permitted",
|
|
102
|
+
"ASKED_SAMPLE_ATTRIBUTES",
|
|
103
|
+
"DocScope",
|
|
104
|
+
"extract_drafts",
|
|
105
|
+
"build_kb_context",
|
|
106
|
+
"build_system_prompt",
|
|
107
|
+
"llm_schema",
|
|
108
|
+
"ExtractionResult",
|
|
109
|
+
"ExtractionOutcome",
|
|
110
|
+
"ExtractUnavailable",
|
|
111
|
+
# providers (the LLM is swappable; nothing downstream trusts it)
|
|
112
|
+
"LLMProvider",
|
|
113
|
+
"LLMResponse",
|
|
114
|
+
"AnthropicProvider",
|
|
115
|
+
"OpenAICompatibleProvider",
|
|
116
|
+
"deepseek_provider",
|
|
117
|
+
"resolve_provider",
|
|
118
|
+
"ProviderUnavailable",
|
|
119
|
+
"ANTHROPIC_DEFAULT_MODEL",
|
|
120
|
+
"DEEPSEEK_DEFAULT_MODEL",
|
|
121
|
+
]
|