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