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
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""``harvest extract`` — **the one LLM touchpoint in the whole compiler**.
|
|
2
|
+
|
|
3
|
+
Everything else in seqforge is a verifier. This module's entire job is to turn prose into
|
|
4
|
+
``AssertionDraft{field, value, quote}`` candidates. It decides nothing:
|
|
5
|
+
|
|
6
|
+
- **No offsets.** The model cannot count characters, so it never emits them — ``verify`` greps the
|
|
7
|
+
quote and computes them (a model-supplied offset would reject truthful claims).
|
|
8
|
+
- **No provenance identity.** ``span.doc_sha256`` is **overwritten by code** after parsing: we know
|
|
9
|
+
which document we sent, so a fabricated or mistyped sha is not a failure mode we need to have.
|
|
10
|
+
- **No verdicts.** The model never asserts that its own quote is real or supportive; ``verify`` owns
|
|
11
|
+
both flags and fails closed.
|
|
12
|
+
- **No trusted shape.** Whatever the provider returns is validated against the canonical Pydantic
|
|
13
|
+
model here. That is what makes the provider swappable (see :mod:`seqforge.harvest.providers`):
|
|
14
|
+
strict-schema providers and json-object providers differ in how *likely* a malformed batch is,
|
|
15
|
+
never in whether one could reach the manifest.
|
|
16
|
+
|
|
17
|
+
The wire schema is derived from ``AssertionDraft`` (design §1.8) — never hand-maintained — so the
|
|
18
|
+
contract cannot drift from ``models/``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, ValidationError
|
|
28
|
+
|
|
29
|
+
from ..kb.schema import Spec
|
|
30
|
+
from ..models.assertion import AssertionDraft, ExtractorProvenance, SourceSpan
|
|
31
|
+
from .fields import describe_asked, fields_for
|
|
32
|
+
from .normalize import NormalizedDoc
|
|
33
|
+
from .providers import LLMProvider, ProviderUnavailable, resolve_provider, schema_prompt
|
|
34
|
+
|
|
35
|
+
#: Bump on ANY prompt change — it is folded into ExtractorProvenance so a harvest is reproducible and
|
|
36
|
+
#: blamable, and evals treat a prompt edit as a code change (brief §9).
|
|
37
|
+
#: 2026.7.1 — gave `experiment.samples.{tissue,condition}` and `accessions` operational definitions.
|
|
38
|
+
#: `eval run --llm` caught DeepSeek V4-Pro filing standard worm husbandry ("maintained on NGM plates
|
|
39
|
+
#: seeded with E. coli OP50 at 20 C") as an experimental *condition*: a real quote, correctly copied,
|
|
40
|
+
#: pinned to a field it does not belong in. The old prompt said only "everything else: the document's
|
|
41
|
+
#: own wording", which invites exactly that. See `verify.entails` for why span verification cannot catch this class.
|
|
42
|
+
#: 2026.7.2 — `processing.*` becomes askable, of --instruction documents ONLY. Note the
|
|
43
|
+
#: hazard this sits on: 2026.7.1's regression WAS field misassignment, and this adds fields whose
|
|
44
|
+
#: misassignment reaches the aligner. Three things contain it, none of them the prompt — the field
|
|
45
|
+
#: allowlist (`harvest.fields`), the doc-role gate (`verify_drafts`), and the all-five default, which
|
|
46
|
+
#: means a hallucinated instruction can only mislabel the primary matrix, never destroy signal.
|
|
47
|
+
#: 2026.7.3 — dropped the hand-written `experiment.samples.condition` definition. `condition` was
|
|
48
|
+
#: removed from the asked vocabulary (no archive defines it; NCBI's `treatment`/`genotype`/
|
|
49
|
+
#: `disease` replaced it), so the prompt was teaching a field `verify` is guaranteed to reject as
|
|
50
|
+
#: `field_not_permitted`: wasted extraction, and a standing invitation to re-file husbandry the way
|
|
51
|
+
#: 2026.7.1 did. Also trimmed the `tissue` gloss — it duplicated the NCBI definition `describe_asked`
|
|
52
|
+
#: now supplies per attribute and conflated tissue with `cell_type` (its own attribute since 7.2).
|
|
53
|
+
#: `test_prompt_names_only_permitted_fields` derives the ⊆ PERMITTED_FIELDS invariant so the prompt
|
|
54
|
+
#: cannot drift from `fields.py` again — the hand-maintained-mirror rot this whole module warns about.
|
|
55
|
+
#: 2026.7.4 — added `library.prep_type` (single-cell vs single-nucleus). It is the biology twin of the
|
|
56
|
+
#: `processing.quantification` caution above: the model reports the INPUT MATERIAL in the document's
|
|
57
|
+
#: own words and still names no feature — "single nuclei" is a prep, not GeneFull. Code owns the
|
|
58
|
+
#: nuclei->GeneFull-primary mapping (`manifest.policy`), so span verification stays a check on the
|
|
59
|
+
#: quote, never a licence to infer a processing decision from biology.
|
|
60
|
+
EXTRACT_PROMPT_VERSION = "2026.7.4"
|
|
61
|
+
|
|
62
|
+
_INSTRUCTIONS = """\
|
|
63
|
+
You extract factual claims from a scientific methods document into structured assertions, returned as
|
|
64
|
+
json.
|
|
65
|
+
|
|
66
|
+
You are one stage of a deterministic compiler. Downstream code independently re-greps every quote you
|
|
67
|
+
produce and checks that the quote supports the value. Claims that fail either check are DISCARDED, so
|
|
68
|
+
inventing or stretching a claim gains nothing — it only wastes the extraction.
|
|
69
|
+
|
|
70
|
+
Rules:
|
|
71
|
+
1. Extract ONLY what the document explicitly states. Never use background knowledge, never infer, and
|
|
72
|
+
never complete a pattern. If the document does not state a field, omit it.
|
|
73
|
+
2. `quote` must be a VERBATIM, contiguous substring copied from the document text, exactly as it
|
|
74
|
+
appears. Do not paraphrase, normalize, join across a gap, or fix typos.
|
|
75
|
+
3. The quote must, ON ITS OWN, support the value. A reader seeing only that quote must be able to
|
|
76
|
+
conclude the value from it. A quote that merely sits near the fact is not enough: for example,
|
|
77
|
+
"we performed single-cell RNA-seq" does NOT support a specific chemistry version.
|
|
78
|
+
4. Keep the quote tight — the shortest span that still supports the value.
|
|
79
|
+
5. Return an empty `drafts` list if the document supports nothing. That is a CORRECT and common answer.
|
|
80
|
+
6. Never emit character offsets. Code computes them.
|
|
81
|
+
7. `llm_confidence` (0.0-1.0) is how sure you are that the document states the claim — not how
|
|
82
|
+
plausible the claim is in general.
|
|
83
|
+
|
|
84
|
+
Values:
|
|
85
|
+
- `library.chemistry`: use the knowledge-base `id` when the document names that technology by any of
|
|
86
|
+
its aliases. If the document names a technology not in the knowledge base, use the document's own
|
|
87
|
+
wording.
|
|
88
|
+
- `library.prep_type`: whether the sequenced input was whole CELLS or isolated NUCLEI, copied in the
|
|
89
|
+
document's own words ("single-nucleus", "single nuclei", "snRNA-seq", "isolated nuclei", or
|
|
90
|
+
"single-cell", "scRNA-seq", "whole cells"). Report only the PREP the document states; do NOT name a
|
|
91
|
+
STARsolo feature or an analysis mode — "single nuclei" is an input material, not `GeneFull`. Omit if
|
|
92
|
+
the document does not say.
|
|
93
|
+
- `experiment.organism`: the scientific name as written (e.g. "Caenorhabditis elegans").
|
|
94
|
+
- `experiment.accessions`: only an explicit database accession (GEO/SRA/ENA/BioProject, e.g.
|
|
95
|
+
"GSE110823", "PRJNA1027859"). A reference genome or assembly name is NOT an accession.
|
|
96
|
+
- `experiment.samples.tissue`: a whole organism at a life stage ("adult worm", "L4 larva") is NOT a
|
|
97
|
+
tissue — omit it rather than filing the life stage here (that is `dev_stage`). Each asked sample
|
|
98
|
+
attribute arrives with its own NCBI definition; keep each value in its own field — a cell type is
|
|
99
|
+
`cell_type`, a perturbation is `treatment`, a mutation is `genotype`, none of them `tissue`.
|
|
100
|
+
- `processing.quantification`: the STARsolo feature the document NAMES, exactly, as one of: Gene, SJ,
|
|
101
|
+
GeneFull, GeneFull_ExonOverIntron, GeneFull_Ex50pAS, Velocyto. Emit one assertion per feature named.
|
|
102
|
+
Only extract this when the document names the feature; a document describing the BIOLOGY ("single
|
|
103
|
+
nuclei", "pre-mRNA", "include introns") does NOT name a feature, and inferring one from biology is
|
|
104
|
+
not your job — omit the field. Asking for GeneFull adds it; it never removes anything else.
|
|
105
|
+
- `processing.genome.assembly`: the UCSC assembly id the document NAMES (e.g. "ce11", "hg38",
|
|
106
|
+
"mm39"). An organism name is not an assembly — omit the field rather than translating one.
|
|
107
|
+
- everything else: the document's own wording.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ExtractUnavailable(RuntimeError):
|
|
112
|
+
"""The LLM surface could not produce a usable batch (no provider, API error, or bad shape)."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ExtractionResult(BaseModel):
|
|
116
|
+
"""The model's structured-output surface: a batch of drafts and nothing else.
|
|
117
|
+
|
|
118
|
+
A thin container over the canonical :class:`AssertionDraft` (structured outputs need an object at
|
|
119
|
+
the top level) — deliberately NOT a second hand-maintained schema.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
drafts: list[AssertionDraft]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class ExtractionOutcome:
|
|
127
|
+
"""What extract returns: the drafts, who made them, the call MODE, and what it cost."""
|
|
128
|
+
|
|
129
|
+
drafts: list[AssertionDraft]
|
|
130
|
+
extractor: ExtractorProvenance
|
|
131
|
+
provider: str = ""
|
|
132
|
+
model: str = ""
|
|
133
|
+
#: How the call was made — thinking/effort, max_tokens, response_format (see ``LLMResponse.mode``).
|
|
134
|
+
mode: dict[str, object] = field(default_factory=dict)
|
|
135
|
+
usage: dict[str, int] = field(default_factory=dict)
|
|
136
|
+
#: Individual drafts the model returned malformed (e.g. ``value: null``) — dropped, not fatal. Same
|
|
137
|
+
#: shape as ``VerifyReport.rejected`` so a run can report both surfaces the same way.
|
|
138
|
+
rejected: list[dict[str, object]] = field(default_factory=list)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def cache_hit(self) -> bool:
|
|
142
|
+
"""True iff the stable prefix was served from cache (0 across repeats => an invalidator)."""
|
|
143
|
+
return self.usage.get("cache_read_tokens", 0) > 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_kb_context(specs: dict[str, Spec]) -> str:
|
|
147
|
+
"""The stable prefix: what each KB technology is called in the wild.
|
|
148
|
+
|
|
149
|
+
Deterministic and frozen — sorted, no timestamps, no per-request ids — because prefix caching (
|
|
150
|
+
explicit on Anthropic, automatic on DeepSeek) is a byte-prefix match and any change invalidates
|
|
151
|
+
it. This is the alias knowledge that lets the model map a paper's "Chromium Single Cell 3' v3"
|
|
152
|
+
onto the id `10x-3p-gex-v3`; `verify` then checks the same aliases from the same KB, so
|
|
153
|
+
extraction and verification cannot disagree about vocabulary.
|
|
154
|
+
"""
|
|
155
|
+
lines = ["Knowledge-base technologies (use these ids for library.chemistry):", ""]
|
|
156
|
+
for tech_id in sorted(specs):
|
|
157
|
+
spec = specs[tech_id]
|
|
158
|
+
aliases = ", ".join(spec.identity.aliases) if spec.identity.aliases else "(none)"
|
|
159
|
+
lines += [
|
|
160
|
+
f"id: {spec.identity.id}",
|
|
161
|
+
f" name: {spec.identity.name}",
|
|
162
|
+
f" aliases: {aliases}",
|
|
163
|
+
]
|
|
164
|
+
return "\n".join(lines)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_system_prompt(specs: dict[str, Spec], schema: dict[str, Any]) -> str:
|
|
168
|
+
"""Instructions + json contract + KB aliases — one prompt, every provider, one prompt_version."""
|
|
169
|
+
return "\n\n".join([_INSTRUCTIONS, schema_prompt(schema), build_kb_context(specs)])
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _user_content(doc: NormalizedDoc, fields: tuple[str, ...]) -> str:
|
|
173
|
+
"""The per-document half of the prompt: which fields, and the document.
|
|
174
|
+
|
|
175
|
+
The ask is scoped, so a sample record's document is never even asked for a chemistry, and the
|
|
176
|
+
sample-attribute definitions come from NCBI's own list rather than from a paraphrase here — see
|
|
177
|
+
`fields.describe_asked`.
|
|
178
|
+
|
|
179
|
+
Note what this does NOT say: which sample the document is about. It does not need to. The document
|
|
180
|
+
holds one record's prose and nothing else, so "which sample" is answered by which file we handed
|
|
181
|
+
the model, and code already knows the answer because code chose the file.
|
|
182
|
+
"""
|
|
183
|
+
return (
|
|
184
|
+
f"Document sha256: {doc.doc_sha256}\n"
|
|
185
|
+
f"Echo that exact string as span.doc_sha256 on every assertion.\n\n"
|
|
186
|
+
f"Fields to look for (omit any the document does not state):\n"
|
|
187
|
+
+ describe_asked(fields)
|
|
188
|
+
+ "\n\n<document>\n"
|
|
189
|
+
+ doc.text
|
|
190
|
+
+ "\n</document>"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def llm_schema() -> dict[str, Any]:
|
|
195
|
+
"""The wire schema, derived from the canonical model (design §1.8)."""
|
|
196
|
+
return ExtractionResult.model_json_schema()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def extract_drafts(
|
|
200
|
+
doc: NormalizedDoc,
|
|
201
|
+
specs: dict[str, Spec],
|
|
202
|
+
*,
|
|
203
|
+
provider: LLMProvider | None = None,
|
|
204
|
+
model: str | None = None,
|
|
205
|
+
fields: tuple[str, ...] | None = None,
|
|
206
|
+
max_tokens: int = 8000,
|
|
207
|
+
) -> ExtractionOutcome:
|
|
208
|
+
"""Ask a model for span-carrying claims about ``doc``. Proposes only — ``verify`` decides.
|
|
209
|
+
|
|
210
|
+
``fields`` defaults to the set appropriate to the document's SCOPE and ROLE: a reference document
|
|
211
|
+
is never asked about ``processing.*``, and a sample record's document is never asked about the
|
|
212
|
+
chemistry. Asking and enforcing are separate jobs, though — ``verify_drafts`` refuses an
|
|
213
|
+
off-scope field regardless of what was asked, because a prompt is not a boundary.
|
|
214
|
+
"""
|
|
215
|
+
asked = fields if fields is not None else fields_for(doc.scope, doc.role)
|
|
216
|
+
try:
|
|
217
|
+
llm = provider if provider is not None else resolve_provider()
|
|
218
|
+
except ProviderUnavailable as exc:
|
|
219
|
+
raise ExtractUnavailable(str(exc)) from exc
|
|
220
|
+
|
|
221
|
+
chosen = model or llm.default_model()
|
|
222
|
+
schema = llm_schema()
|
|
223
|
+
try:
|
|
224
|
+
response = llm.complete_json(
|
|
225
|
+
system=build_system_prompt(specs, schema),
|
|
226
|
+
user=_user_content(doc, asked),
|
|
227
|
+
schema=schema,
|
|
228
|
+
model=chosen,
|
|
229
|
+
max_tokens=max_tokens,
|
|
230
|
+
)
|
|
231
|
+
except ProviderUnavailable as exc:
|
|
232
|
+
raise ExtractUnavailable(str(exc)) from exc
|
|
233
|
+
|
|
234
|
+
# THE gate. json-object providers do not enforce shape, so this is where a malformed batch is
|
|
235
|
+
# caught. The split is deliberate: a broken TOP-LEVEL shape (no JSON at all, or no `drafts` array)
|
|
236
|
+
# is a provider failure with nothing to salvage and dies wholesale (that is #4's empty-content
|
|
237
|
+
# case). But a single malformed DRAFT — a `value: null`, a missing span — is just one bad proposal
|
|
238
|
+
# from a proposer we already distrust: drop it into `rejected` and keep the rest, exactly as
|
|
239
|
+
# `verify` drops a claim whose quote will not grep back. One flaky token from the model must not
|
|
240
|
+
# sink a whole document's worth of valid extraction (#5).
|
|
241
|
+
try:
|
|
242
|
+
raw = json.loads(response.text)
|
|
243
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
244
|
+
raise ExtractUnavailable(
|
|
245
|
+
f"{llm.name} returned output that is not valid JSON: {exc}"
|
|
246
|
+
) from exc
|
|
247
|
+
if not isinstance(raw, dict):
|
|
248
|
+
raise ExtractUnavailable(
|
|
249
|
+
f"{llm.name} returned a top-level {type(raw).__name__}, not a JSON object with a "
|
|
250
|
+
f"`drafts` array"
|
|
251
|
+
)
|
|
252
|
+
if not isinstance(raw.get("drafts"), list):
|
|
253
|
+
# Name what is actually wrong with `drafts` — missing, or the wrong type ({'drafts': null}
|
|
254
|
+
# reports "null", not the useless "got dict" of the top-level object.
|
|
255
|
+
detail = "missing" if "drafts" not in raw else f"a {type(raw['drafts']).__name__}"
|
|
256
|
+
raise ExtractUnavailable(
|
|
257
|
+
f"{llm.name} returned no `drafts` array: the `drafts` key is {detail}, not a list"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
drafts: list[AssertionDraft] = []
|
|
261
|
+
rejected: list[dict[str, object]] = []
|
|
262
|
+
for item in raw["drafts"]:
|
|
263
|
+
try:
|
|
264
|
+
drafts.append(AssertionDraft.model_validate(item))
|
|
265
|
+
except ValidationError as exc:
|
|
266
|
+
rejected.append(_malformed_draft(item, exc))
|
|
267
|
+
|
|
268
|
+
extractor = ExtractorProvenance(
|
|
269
|
+
# provenance records the provider too: the same prompt on a different model is a different
|
|
270
|
+
# extractor, and evals must be able to tell those runs apart.
|
|
271
|
+
model_id=f"{llm.name}/{chosen}",
|
|
272
|
+
prompt_version=EXTRACT_PROMPT_VERSION,
|
|
273
|
+
)
|
|
274
|
+
return ExtractionOutcome(
|
|
275
|
+
drafts=[_anchor(d, doc) for d in drafts],
|
|
276
|
+
extractor=extractor,
|
|
277
|
+
provider=llm.name,
|
|
278
|
+
model=chosen,
|
|
279
|
+
mode=response.mode,
|
|
280
|
+
usage=response.usage,
|
|
281
|
+
rejected=rejected,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _malformed_draft(item: object, exc: ValidationError) -> dict[str, object]:
|
|
286
|
+
"""One draft the model returned malformed. Recorded in the ``rejected`` channel and dropped — a
|
|
287
|
+
non-fatal echo of ``verify._reject``, so both surfaces read the same way. Kept defensive because
|
|
288
|
+
``item`` failed validation: any field may be missing or the wrong type."""
|
|
289
|
+
span = item.get("span") if isinstance(item, dict) else None
|
|
290
|
+
quote = span.get("quote") if isinstance(span, dict) else None
|
|
291
|
+
errors = exc.errors()
|
|
292
|
+
detail = errors[0]["msg"] if errors else str(exc)
|
|
293
|
+
return {
|
|
294
|
+
"field": item.get("field") if isinstance(item, dict) else None,
|
|
295
|
+
"value": item.get("value") if isinstance(item, dict) else None,
|
|
296
|
+
"quote": quote[:120] if isinstance(quote, str) else None,
|
|
297
|
+
"reason": "malformed_draft",
|
|
298
|
+
"detail": f"draft failed AssertionDraft validation: {detail}",
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _anchor(draft: AssertionDraft, doc: NormalizedDoc) -> AssertionDraft:
|
|
303
|
+
"""Force every draft onto the document we actually sent.
|
|
304
|
+
|
|
305
|
+
We know which document this was; the model's echo of the sha is therefore worthless as evidence
|
|
306
|
+
and dangerous as a failure mode (a mistyped sha would be rejected downstream as `unknown_doc`,
|
|
307
|
+
which looks like a hallucination but is just a typo). Code owns provenance identity — the same
|
|
308
|
+
reason code owns the offsets.
|
|
309
|
+
"""
|
|
310
|
+
return draft.model_copy(
|
|
311
|
+
update={
|
|
312
|
+
"span": SourceSpan(
|
|
313
|
+
doc_sha256=doc.doc_sha256,
|
|
314
|
+
quote=draft.span.quote,
|
|
315
|
+
context=draft.span.context,
|
|
316
|
+
# offsets stay None here on purpose: `verify` computes them from the real text
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""The closed vocabulary of manifest fields an LLM may assert — enforced by code, not by the prompt.
|
|
2
|
+
|
|
3
|
+
``AssertionDraft.field`` is a plain ``str``, and it has to stay one: the wire schema must fit inside
|
|
4
|
+
every provider's strict-schema subset. That makes the vocabulary a **code** obligation rather than a
|
|
5
|
+
type one, and until this module existed there was nothing discharging it. ``DEFAULT_FIELDS`` was only
|
|
6
|
+
ever interpolated into the prompt; ``verify`` never compared a returned draft against it. So the
|
|
7
|
+
model could name any field it liked and both span-verification checks would still pass:
|
|
8
|
+
|
|
9
|
+
field: "processing.params.outFilterMismatchNmax" value: "10"
|
|
10
|
+
quote: "add --outFilterMismatchNmax 10 to the alignment"
|
|
11
|
+
|
|
12
|
+
That quote is real, it is contiguous, and it genuinely entails "10". ``span_verified`` and
|
|
13
|
+
``entailment_ok`` both hold. Span verification is working exactly as designed and it does not help,
|
|
14
|
+
because it asks *"is this claim in the document?"* and the question here is *"is this a field you may
|
|
15
|
+
set at all?"*. Prose would have become aligner argv, which is precisely what we forbid.
|
|
16
|
+
|
|
17
|
+
**Asking and enforcing are different jobs.** The prompt asks for these fields; this module refuses
|
|
18
|
+
everything else. Conflating the two is how a prompt quietly becomes a security boundary — and a
|
|
19
|
+
prompt is the one component here we cannot make deterministic.
|
|
20
|
+
|
|
21
|
+
**Two axes, not one: role and scope.**
|
|
22
|
+
|
|
23
|
+
``role`` is what a document IS to us — a reference we cite, or an instruction written for us. It
|
|
24
|
+
decides whether ``processing.*`` is askable at all.
|
|
25
|
+
|
|
26
|
+
``scope`` is what a document is ABOUT, and it is the newer of the two. Each level of an archive
|
|
27
|
+
record is rendered as its own document, so a sample-level document holds one sample's fields and
|
|
28
|
+
nothing else. That is what lets a claim name a sample without ``AssertionDraft`` growing a
|
|
29
|
+
``subject`` field: the subject is the document, and code chose the document. A ``dataset``-scoped
|
|
30
|
+
document (a paper, a README) is about the whole pile of files, so it may make dataset-wide claims,
|
|
31
|
+
and ``resolve`` records its sample claims as *inferred* rather than asserted — see
|
|
32
|
+
:mod:`seqforge.resolve.records`.
|
|
33
|
+
|
|
34
|
+
Both are set from **how the document arrived**, never from its contents. A filename trigger would be
|
|
35
|
+
magic, unauditable, and spoofable by renaming a download.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from typing import Literal
|
|
41
|
+
|
|
42
|
+
from ..io.attributes import get_attribute, is_attribute
|
|
43
|
+
|
|
44
|
+
DocRole = Literal["reference", "instruction"]
|
|
45
|
+
"""What a document IS to us — decided by the flag it arrived under, never by its filename.
|
|
46
|
+
|
|
47
|
+
``alignment_instruction.md`` is merely the *conventional* name you pass to ``--instruction``; it is
|
|
48
|
+
load-bearing nowhere.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
DocScope = Literal["dataset", "project", "sample", "experiment", "run"]
|
|
52
|
+
"""What a document is ABOUT — decided by which record produced it, never by the model.
|
|
53
|
+
|
|
54
|
+
``dataset`` is the default and the only scope a human-supplied document ever has: you hand us a paper
|
|
55
|
+
about the whole thing. The other four are archive record levels, and a document at one of them was
|
|
56
|
+
rendered by code from a record code fetched.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
#: The sample attributes worth spending prompt tokens on. Hand-picked from NCBI's 960 — **asking**
|
|
60
|
+
#: for 960 fields would be absurd, and the ones here are the ones that describe a sample well enough
|
|
61
|
+
#: to analyse it: what it was, what was done to it, and who it was.
|
|
62
|
+
#:
|
|
63
|
+
#: Every name is NCBI's, and `test_every_asked_attribute_is_one_ncbi_defines` checks that against the
|
|
64
|
+
#: shipped vocabulary rather than against a second list here. `condition` used to be in this position
|
|
65
|
+
#: and was **ours** — no archive defines it, and a field named "condition" accepts anything you can
|
|
66
|
+
#: call a condition, which is how the pilot's extraction filed routine worm husbandry ("maintained on
|
|
67
|
+
#: NGM plates seeded with E. coli OP50 at 20 C") into it. NCBI's `treatment`, `genotype` and
|
|
68
|
+
#: `disease` are what it should have been all along, and each arrives with a definition somebody else
|
|
69
|
+
#: maintains.
|
|
70
|
+
ASKED_SAMPLE_ATTRIBUTES: tuple[str, ...] = (
|
|
71
|
+
"tissue",
|
|
72
|
+
"cell_type",
|
|
73
|
+
"strain",
|
|
74
|
+
"genotype",
|
|
75
|
+
"treatment",
|
|
76
|
+
"disease",
|
|
77
|
+
"sex",
|
|
78
|
+
"dev_stage",
|
|
79
|
+
"age",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
#: Manifest paths worth asking of a document about the whole dataset. ``library.*`` is byte-decidable
|
|
83
|
+
#: and only ever a HYPOTHESIS here (resolve owns the decision, §3.4); ``experiment.*`` is the part
|
|
84
|
+
#: bytes genuinely cannot see.
|
|
85
|
+
DEFAULT_FIELDS: tuple[str, ...] = (
|
|
86
|
+
"library.chemistry",
|
|
87
|
+
# A biology fact, NOT byte-decidable and NOT a hypothesis: whether the input was whole cells or
|
|
88
|
+
# isolated nuclei. No byte in a FASTQ says which, and the model names only the biology ("single
|
|
89
|
+
# nuclei") — never a feature. Code maps it to the primary soloFeature (GeneFull for nuclei), which
|
|
90
|
+
# is why it is safe to source from prose: the blast radius is which matrix is primary, and all five
|
|
91
|
+
# are computed regardless. See `manifest.policy.resolve_features`.
|
|
92
|
+
"library.prep_type",
|
|
93
|
+
"experiment.organism",
|
|
94
|
+
"experiment.accessions",
|
|
95
|
+
*(f"experiment.samples.{a}" for a in ASKED_SAMPLE_ATTRIBUTES),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
#: Manifest paths asked ONLY of a document handed to us under ``--instruction``.
|
|
99
|
+
#:
|
|
100
|
+
#: A downloaded methods PDF may never set these: a GEO description is an untrusted input, and prose
|
|
101
|
+
#: reaching ``--soloStrand`` would be a prompt-injection path from a database field into an aligner.
|
|
102
|
+
#: With the default counting everything, excluding reference docs costs nothing — a paper saying
|
|
103
|
+
#: "we used GeneFull" describes a subset of what we already compute.
|
|
104
|
+
#:
|
|
105
|
+
#: `processing.genome.annotation_name` is deliberately absent: it is a liulab-genome registry name
|
|
106
|
+
#: (`WS298`), a vocabulary no paper writes in, so asking for it would only invite a guess. It stays a
|
|
107
|
+
#: CLI flag. Each field added here costs prompt tokens on every extraction and needs eval coverage —
|
|
108
|
+
#: keep the surface to what earns it.
|
|
109
|
+
INSTRUCTION_FIELDS: tuple[str, ...] = (
|
|
110
|
+
"processing.quantification",
|
|
111
|
+
"processing.genome.assembly",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
#: What each archive record level may say, and it is a strict narrowing rather than a convenience.
|
|
115
|
+
#:
|
|
116
|
+
#: - ``sample``: this record's own attributes, and nothing else. A BioSample record has no opinion
|
|
117
|
+
#: about the chemistry, so asking it for one would only invite a guess from an alias.
|
|
118
|
+
#: - ``experiment``: the chemistry from the protocol paragraph ("Single Cell 3' v3.1 Reagent Kits ...
|
|
119
|
+
#: 28+94 nt pair-end reads"), which enters ``resolve`` as a hypothesis and never as evidence — plus
|
|
120
|
+
#: ``treatment``, and *only* ``treatment``. An experiment's title is the GEO GSM title ("Day1
|
|
121
|
+
#: Wild-type(N2) feed with E. coli OP50"), and an experiment belongs to exactly one sample, so a
|
|
122
|
+
#: treatment claim from it is a declaration ABOUT that sample — ``asserted`` via the same
|
|
123
|
+
#: ``subject_to_sample`` join that maps a run alias home, one level up. Treatment alone because the
|
|
124
|
+
#: diet is the one variable that lives ONLY in that title: a BioSample owns ``strain``, ``age`` and
|
|
125
|
+
#: ``tissue`` as typed fields, so asking the title for those too would only let a formatting
|
|
126
|
+
#: difference ("Day6" vs the record's "day6") null a value the record had already resolved. GSE229022
|
|
127
|
+
#: is 28 samples whose OP50/HT115/BW25113/delta-lon contrast is written nowhere else.
|
|
128
|
+
#: - ``project``: nothing, deliberately. The study abstract is normalized into a document so a fact
|
|
129
|
+
#: *could* cite it, and no model reads it today: "wild-type and daf-2 mutants" is true of the study
|
|
130
|
+
#: and false of any single sample, and project-level facts (title, centre, data type) are
|
|
131
|
+
#: structured fields we transcribe rather than prose we interpret.
|
|
132
|
+
#: - ``run``: the same sample attributes as a sample document. A run alias ("N2_wild_type",
|
|
133
|
+
#: "daf-2_R3", "Rep3 daf2 reads") is often the ONLY place the WT-vs-mutant contrast is written in
|
|
134
|
+
#: plain words, and it is a per-run declaration of its sample's condition. A run belongs to exactly
|
|
135
|
+
#: one sample (run -> experiment -> sample, joined by code), so a claim from its document is a
|
|
136
|
+
#: declaration ABOUT that sample and ``_basis_for`` maps it home as ``asserted``. This was ``()`` on
|
|
137
|
+
#: the theory that the sample's own alias said the same thing; the pilot falsified it — harvest read
|
|
138
|
+
#: the "WT" alias from one of six sample documents and the paper's "daf-2" then fanned onto the two
|
|
139
|
+
#: wild-type samples it missed. The run aliases said "N2_wild_type" plainly, and went unread.
|
|
140
|
+
_SCOPE_FIELDS: dict[DocScope, tuple[str, ...]] = {
|
|
141
|
+
"dataset": DEFAULT_FIELDS,
|
|
142
|
+
"sample": tuple(f"experiment.samples.{a}" for a in ASKED_SAMPLE_ATTRIBUTES),
|
|
143
|
+
"experiment": ("library.chemistry", "experiment.samples.treatment"),
|
|
144
|
+
"project": (),
|
|
145
|
+
"run": tuple(f"experiment.samples.{a}" for a in ASKED_SAMPLE_ATTRIBUTES),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#: Every field any draft may carry, from any document. A draft naming anything else is rejected by
|
|
149
|
+
#: ``verify_drafts`` with ``field_not_permitted`` — fail-closed, code-owned, no passthrough.
|
|
150
|
+
#:
|
|
151
|
+
#: Keep this derived and explicit. The temptation is a prefix rule ("anything under ``experiment.``"),
|
|
152
|
+
#: which re-opens exactly the hole above: ``experiment.samples.tissue`` is a field, and
|
|
153
|
+
#: ``experiment.anything.you.can.name`` must not be.
|
|
154
|
+
PERMITTED_FIELDS: frozenset[str] = frozenset(DEFAULT_FIELDS) | frozenset(INSTRUCTION_FIELDS)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def fields_for(scope: DocScope, role: DocRole) -> tuple[str, ...]:
|
|
158
|
+
"""Which fields to ASK of a document with this scope and role. Enforcement is :func:`permitted_for`."""
|
|
159
|
+
base = _SCOPE_FIELDS.get(scope, ())
|
|
160
|
+
if role == "instruction":
|
|
161
|
+
return (*base, *INSTRUCTION_FIELDS)
|
|
162
|
+
return base
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def permitted_for(field: str, scope: DocScope, role: DocRole) -> bool:
|
|
166
|
+
"""May a draft from a document with this scope and role set this field? Fail-closed."""
|
|
167
|
+
return field in frozenset(fields_for(scope, role))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def fields_for_role(role: DocRole) -> tuple[str, ...]:
|
|
171
|
+
"""Back-compat shim: the dataset-scoped ask. A document with no record behind it is dataset-wide."""
|
|
172
|
+
return fields_for("dataset", role)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def is_permitted(field: str) -> bool:
|
|
176
|
+
"""Is ``field`` a manifest path the LLM is allowed to assert at all, from any document?"""
|
|
177
|
+
return field in PERMITTED_FIELDS
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def describe_asked(fields: tuple[str, ...]) -> str:
|
|
181
|
+
"""The ask, with NCBI's definition beside each sample attribute — in NCBI's words, not ours.
|
|
182
|
+
|
|
183
|
+
A definition we paraphrase is a definition that drifts from the vocabulary it claims to quote, and
|
|
184
|
+
a prompt is the worst possible place to keep one: nothing checks it, and it is exactly where the
|
|
185
|
+
pilot's misfiling happened. So the text handed to the model comes out of the shipped file NCBI's
|
|
186
|
+
own list was generated into.
|
|
187
|
+
"""
|
|
188
|
+
lines: list[str] = []
|
|
189
|
+
for f in fields:
|
|
190
|
+
name = f.removeprefix("experiment.samples.")
|
|
191
|
+
if f.startswith("experiment.samples.") and is_attribute(name):
|
|
192
|
+
attr = get_attribute(name)
|
|
193
|
+
gloss = attr.description or attr.display or name
|
|
194
|
+
lines.append(f"- {f}: {gloss} (NCBI BioSample attribute {name!r})")
|
|
195
|
+
else:
|
|
196
|
+
lines.append(f"- {f}")
|
|
197
|
+
return "\n".join(lines)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
__all__ = [
|
|
201
|
+
"DEFAULT_FIELDS",
|
|
202
|
+
"INSTRUCTION_FIELDS",
|
|
203
|
+
"PERMITTED_FIELDS",
|
|
204
|
+
"ASKED_SAMPLE_ATTRIBUTES",
|
|
205
|
+
"DocRole",
|
|
206
|
+
"DocScope",
|
|
207
|
+
"fields_for",
|
|
208
|
+
"fields_for_role",
|
|
209
|
+
"permitted_for",
|
|
210
|
+
"is_permitted",
|
|
211
|
+
"describe_asked",
|
|
212
|
+
]
|