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.
Files changed (124) hide show
  1. seqforge/__init__.py +16 -0
  2. seqforge/cli/__init__.py +38 -0
  3. seqforge/cli/__main__.py +8 -0
  4. seqforge/cli/_common.py +105 -0
  5. seqforge/cli/compose.py +119 -0
  6. seqforge/cli/eval.py +103 -0
  7. seqforge/cli/harvest.py +417 -0
  8. seqforge/cli/hook.py +247 -0
  9. seqforge/cli/io.py +502 -0
  10. seqforge/cli/kb.py +348 -0
  11. seqforge/cli/manifest.py +536 -0
  12. seqforge/cli/probe.py +43 -0
  13. seqforge/cli/processing.py +192 -0
  14. seqforge/cli/project.py +52 -0
  15. seqforge/cli/resolve.py +55 -0
  16. seqforge/cli/root.py +66 -0
  17. seqforge/cli/run.py +463 -0
  18. seqforge/cli/schema.py +41 -0
  19. seqforge/compose/__init__.py +28 -0
  20. seqforge/compose/core.py +515 -0
  21. seqforge/compose/gates.py +113 -0
  22. seqforge/compose/params.py +447 -0
  23. seqforge/e2e.py +1926 -0
  24. seqforge/evals/__init__.py +78 -0
  25. seqforge/evals/case.py +382 -0
  26. seqforge/evals/grade.py +300 -0
  27. seqforge/evals/run.py +420 -0
  28. seqforge/harvest/__init__.py +121 -0
  29. seqforge/harvest/extract.py +319 -0
  30. seqforge/harvest/fields.py +212 -0
  31. seqforge/harvest/normalize.py +537 -0
  32. seqforge/harvest/prep.py +41 -0
  33. seqforge/harvest/providers.py +321 -0
  34. seqforge/harvest/verify.py +251 -0
  35. seqforge/hooks/__init__.py +33 -0
  36. seqforge/hooks/guards.py +214 -0
  37. seqforge/io/__init__.py +61 -0
  38. seqforge/io/archive.py +450 -0
  39. seqforge/io/attributes.py +190 -0
  40. seqforge/io/biosample/attributes.json +6341 -0
  41. seqforge/io/efo/labels.json +55 -0
  42. seqforge/io/efo.py +138 -0
  43. seqforge/io/onlist.py +661 -0
  44. seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
  45. seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
  46. seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
  47. seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
  48. seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
  49. seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
  50. seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
  51. seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
  52. seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
  53. seqforge/io/onlists/index.json +74 -0
  54. seqforge/io/remote.py +659 -0
  55. seqforge/io/taxonomy.py +194 -0
  56. seqforge/kb/__init__.py +62 -0
  57. seqforge/kb/anchor.py +169 -0
  58. seqforge/kb/generate.py +147 -0
  59. seqforge/kb/loader.py +152 -0
  60. seqforge/kb/roundtrip.py +112 -0
  61. seqforge/kb/schema.py +422 -0
  62. seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
  63. seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
  64. seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
  65. seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
  66. seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
  67. seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
  68. seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
  69. seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
  70. seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
  71. seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
  72. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
  73. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
  74. seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
  75. seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
  76. seqforge/kb/specs/splitseq/README.md +51 -0
  77. seqforge/kb/specs/splitseq/spec.yaml +157 -0
  78. seqforge/manifest/__init__.py +61 -0
  79. seqforge/manifest/fill.py +531 -0
  80. seqforge/manifest/hash.py +77 -0
  81. seqforge/manifest/instruct.py +114 -0
  82. seqforge/manifest/policy.py +409 -0
  83. seqforge/manifest/validate.py +274 -0
  84. seqforge/models/__init__.py +268 -0
  85. seqforge/models/assertion.py +68 -0
  86. seqforge/models/base.py +100 -0
  87. seqforge/models/blocker.py +71 -0
  88. seqforge/models/conflict.py +47 -0
  89. seqforge/models/dataset.py +320 -0
  90. seqforge/models/evidenced.py +54 -0
  91. seqforge/models/observation.py +157 -0
  92. seqforge/models/processing.py +231 -0
  93. seqforge/models/records.py +145 -0
  94. seqforge/models/resolve.py +216 -0
  95. seqforge/probe/__init__.py +46 -0
  96. seqforge/probe/core.py +232 -0
  97. seqforge/probe/signals.py +250 -0
  98. seqforge/probe/streaming.py +118 -0
  99. seqforge/project.py +177 -0
  100. seqforge/py.typed +0 -0
  101. seqforge/resolve/__init__.py +98 -0
  102. seqforge/resolve/assign.py +204 -0
  103. seqforge/resolve/cache.py +119 -0
  104. seqforge/resolve/confuse.py +215 -0
  105. seqforge/resolve/engine.py +646 -0
  106. seqforge/resolve/escalate.py +668 -0
  107. seqforge/resolve/evaluators.py +306 -0
  108. seqforge/resolve/geometry.py +89 -0
  109. seqforge/resolve/group.py +85 -0
  110. seqforge/resolve/records.py +550 -0
  111. seqforge/resolve/scoring.py +373 -0
  112. seqforge/resolve/window.py +206 -0
  113. seqforge/workflows/__init__.py +234 -0
  114. seqforge/workflows/cram.py +117 -0
  115. seqforge/workflows/h5ad.py +368 -0
  116. seqforge/workflows/map/star.smk +101 -0
  117. seqforge/workflows/map/starsolo.smk +360 -0
  118. seqforge/workflows/qc.py +157 -0
  119. seqforge/workspace.py +125 -0
  120. seqforge-2026.7.1.dist-info/METADATA +125 -0
  121. seqforge-2026.7.1.dist-info/RECORD +124 -0
  122. seqforge-2026.7.1.dist-info/WHEEL +4 -0
  123. seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
  124. 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
+ ]