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,274 @@
1
+ """``manifest validate`` — refusal is an exit code, not a vibe.
2
+
3
+ Returns a structured :class:`ValidationReport`; the CLI maps it to the uniform exit contract
4
+ (``0`` OK, ``3`` BLOCKED, ``4`` NEEDS_HUMAN). ``manifest.yaml`` is written **only** after a clean
5
+ validate.
6
+
7
+ Type-level guards (the ``Uri`` validator's absolute-path rejection, the ``AssayTerm`` CURIE pattern,
8
+ the ``Sha256`` pattern) already fail at *construction*; this pass owns the checks Pydantic cannot do
9
+ locally — **referential integrity across sections**, controlled-vocabulary presence, and
10
+ role/layout/onlist coherence. The absolute-path sweep is kept as defence in depth: no-absolute-path is
11
+ the rule most expensive to get wrong, so it is enforced twice.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from ..models.blocker import Blocker, BlockerCode, BlockerSubject, ValidationWarning
17
+ from ..models.conflict import Conflict
18
+ from ..models.dataset import INDEX_ROLE, DatasetManifest
19
+ from ..models.processing import ProcessingManifest
20
+ from ..models.resolve import ValidationReport
21
+
22
+
23
+ def _looks_absolute(uri: str) -> bool:
24
+ return (
25
+ uri.startswith(("/", "~"))
26
+ or uri.startswith("file:///")
27
+ or (len(uri) > 1 and uri[1] == ":")
28
+ or uri.startswith("\\\\")
29
+ )
30
+
31
+
32
+ def validate_manifest(
33
+ manifest: DatasetManifest,
34
+ *,
35
+ conflicts: list[Conflict] | None = None,
36
+ warnings: list[ValidationWarning] | None = None,
37
+ ) -> ValidationReport:
38
+ """Validate a manifest's cross-section integrity. Any Blocker => not compilable.
39
+
40
+ ``warnings`` seeds the report's advisory notes — the metadata resolver's non-blocking
41
+ sample-attribute decisions (kept-by-precedence or left-null) arrive here — and never touch ``ok``.
42
+ Only an ``open`` conflict or a Blocker makes a manifest non-compilable.
43
+ """
44
+ blockers: list[Blocker] = []
45
+ warnings = list(warnings or [])
46
+ open_conflicts = [c for c in (conflicts or []) if c.status == "open"]
47
+
48
+ # --- no absolute/local path may ever reach a manifest (defence in depth) ---
49
+ for f in manifest.library.files:
50
+ if _looks_absolute(f.uri):
51
+ blockers.append(
52
+ Blocker(
53
+ id=f"blk-abspath-{f.sha256[:8]}",
54
+ code=BlockerCode.ABSOLUTE_PATH,
55
+ message=f"file uri {f.uri!r} is an absolute/local path.",
56
+ remedy="Use a relative path, a non-file scheme (s3://, gs://, https://), or an accession.",
57
+ subject=BlockerSubject(kind="file", ref=f.basename),
58
+ )
59
+ )
60
+ for ol in manifest.library.onlists:
61
+ if _looks_absolute(ol.uri):
62
+ blockers.append(
63
+ Blocker(
64
+ id=f"blk-abspath-onlist-{ol.name}",
65
+ code=BlockerCode.ABSOLUTE_PATH,
66
+ message=f"onlist {ol.name!r} uri {ol.uri!r} is an absolute/local path.",
67
+ remedy="Register the onlist by URL + sha256; it resolves to a cache path at run time.",
68
+ subject=BlockerSubject(kind="field", ref=f"library.onlists.{ol.name}"),
69
+ )
70
+ )
71
+
72
+ # --- referential integrity: every experiment file_uri must exist in the library inventory ---
73
+ inventory = {f.uri for f in manifest.library.files}
74
+ for sample in manifest.experiment.samples:
75
+ for uri in sample.file_uris:
76
+ if uri not in inventory:
77
+ blockers.append(
78
+ Blocker(
79
+ id=f"blk-refint-{sample.sample_id}-{uri}",
80
+ code=BlockerCode.UNRESOLVED_CONFLICT,
81
+ message=(
82
+ f"sample {sample.sample_id!r} references {uri!r}, which is not in the "
83
+ "library file inventory."
84
+ ),
85
+ remedy="Add the file to library.files, or correct the sample's file_uris.",
86
+ subject=BlockerSubject(
87
+ kind="field", ref=f"experiment.samples.{sample.sample_id}"
88
+ ),
89
+ )
90
+ )
91
+
92
+ # --- controlled vocabulary must be present (the corpus is only filterable if lineage is stable) ---
93
+ if not manifest.library.chemistry.value:
94
+ blockers.append(
95
+ Blocker(
96
+ id="blk-vocab-chemistry",
97
+ code=BlockerCode.MISSING_CONTROLLED_VOCAB,
98
+ message="library.chemistry is empty — no technology was recorded.",
99
+ remedy="Re-run `seqforge resolve score`; a manifest requires a decided chemistry.",
100
+ subject=BlockerSubject(kind="field", ref="library.chemistry"),
101
+ )
102
+ )
103
+
104
+ # --- role/layout coherence: an assigned read_id must name a read in the layout ---
105
+ # INDEX_ROLE is exempt: a technical sample-index read is deliberately not in the layout (STARsolo
106
+ # never consumes it), so it is set aside rather than matched to a declared read.
107
+ layout_roles = {r.read_id for r in manifest.library.read_layout.reads}
108
+ for f in manifest.library.files:
109
+ if f.read_id is not None and f.read_id != INDEX_ROLE and f.read_id not in layout_roles:
110
+ blockers.append(
111
+ Blocker(
112
+ id=f"blk-role-{f.sha256[:8]}",
113
+ code=BlockerCode.NO_VALID_ROLE_ASSIGNMENT,
114
+ message=(
115
+ f"{f.basename} is assigned role {f.read_id!r}, which is not a read in "
116
+ f"the declared layout ({sorted(layout_roles)})."
117
+ ),
118
+ remedy="Re-run `seqforge resolve score`; the role assignment must match the layout.",
119
+ subject=BlockerSubject(kind="file", ref=f.basename),
120
+ )
121
+ )
122
+ for role in sorted(layout_roles):
123
+ if not any(f.read_id == role for f in manifest.library.files):
124
+ blockers.append(
125
+ Blocker(
126
+ id=f"blk-unfilled-{role}",
127
+ code=BlockerCode.MISSING_TECHNICAL_READ,
128
+ message=f"the declared layout needs read {role!r}, but no file fills it.",
129
+ remedy=(
130
+ "Re-fetch with `fasterq-dump --include-technical`, or pull the original "
131
+ "submitted files `sra-pub-src-*` via the SRA Data Locator / SDL API."
132
+ ),
133
+ subject=BlockerSubject(kind="field", ref=f"library.read_layout.{role}"),
134
+ )
135
+ )
136
+
137
+ # --- every file must have a role: a file with none is a file we will silently not process ---
138
+ #
139
+ # This is the check that was missing, and its absence is how a 6-run dataset validated clean while
140
+ # 5/6 of it evaporated. `resolve` did ONE global assignment across all 12 files, so ten came back
141
+ # with `read_id=None`; `compose._units` skips those without a word; the manifest was
142
+ # content-addressed and blessed. Exit 0, wrong answer, no symptom.
143
+ #
144
+ # The inverse check above ("is every declared role filled?") passed the whole time, because it
145
+ # only ever needed ONE file per role. Both directions are needed and only one existed.
146
+ #
147
+ # `read_id is None` still means *dropped*, and still blocks: a legitimately-ignored technical
148
+ # index read is tagged INDEX_ROLE (not None) by the resolver's length gate, so it never reaches
149
+ # here. The gate is why that stays honest — it only sets a leftover aside when the bytes say it is
150
+ # index-sized (<= 20 bp); a cDNA-length leftover keeps read_id=None and blocks loudly below.
151
+ for f in manifest.library.files:
152
+ if f.read_id is None:
153
+ blockers.append(
154
+ Blocker(
155
+ id=f"blk-unassigned-{f.sha256[:8]}",
156
+ code=BlockerCode.NO_VALID_ROLE_ASSIGNMENT,
157
+ message=(
158
+ f"{f.basename} was given no read role, so the pipeline would not read it. "
159
+ f"Its reads would be dropped, and nothing downstream would say so."
160
+ ),
161
+ remedy=(
162
+ "Usually this means the files were resolved as one library when they are "
163
+ "several runs: use `seqforge manifest fill` on the whole set (it groups by "
164
+ "run and assigns roles per run), or drop the file if it does not belong to "
165
+ "this dataset."
166
+ ),
167
+ subject=BlockerSubject(kind="file", ref=f.basename),
168
+ )
169
+ )
170
+
171
+ # --- onlists: a barcode element naming an unmaterialized whitelist is advisory, not fatal ---
172
+ onlist_names = {o.name for o in manifest.library.onlists}
173
+ for read in manifest.library.read_layout.reads:
174
+ for el in read.elements:
175
+ if el.onlist_ref and el.onlist_ref not in onlist_names:
176
+ warnings.append(
177
+ ValidationWarning(
178
+ code="ONLIST_UNRESOLVED",
179
+ message=(
180
+ f"read {read.read_id} element {el.role} references onlist "
181
+ f"{el.onlist_ref!r}, which is not registered in this manifest; it must "
182
+ "resolve (URL + sha256) before compose can emit a whitelist path."
183
+ ),
184
+ subject=BlockerSubject(
185
+ kind="field", ref=f"library.read_layout.{read.read_id}"
186
+ ),
187
+ )
188
+ )
189
+
190
+ return ValidationReport(
191
+ ok=not blockers and not open_conflicts,
192
+ blockers=blockers,
193
+ conflicts=open_conflicts,
194
+ warnings=warnings,
195
+ )
196
+
197
+
198
+ def validate_processing(
199
+ processing: ProcessingManifest,
200
+ *,
201
+ dataset: DatasetManifest | None = None,
202
+ conflicts: list[Conflict] | None = None,
203
+ ) -> ValidationReport:
204
+ """Validate one processing manifest, and its coherence with the dataset it will be paired with.
205
+
206
+ Most of the intent surface needs no checking here: it is closed vocabulary enforced at
207
+ construction (``SoloFeature``, ``RuntimeEnv``), and the parse/count line means a user has no
208
+ vocabulary in which to contradict the bytes at all.
209
+
210
+ **Genome is the exception, and it is the one worth the code.** A user may instruct
211
+ ``assembly: hg38`` on a *C. elegans* dataset. That contradicts no byte — the probe cannot see
212
+ organism — it contradicts ``experiment.organism``, which is itself ``asserted``. And a
213
+ wrong-but-valid assembly is the worst failure this system can produce: STAR aligns, exits 0, and
214
+ emits a plausible matrix in the wrong coordinate space. Every other check in this file catches
215
+ something that would otherwise crash or look empty; this one catches something that looks *fine*.
216
+
217
+ Deliberately narrow: it fires only when the manifest already carries an ``ncbi_taxid`` for the
218
+ genome. A full assembly->taxid table belongs in ``liulab-genome``, not here.
219
+ """
220
+ blockers: list[Blocker] = []
221
+ open_conflicts = [c for c in (conflicts or []) if c.status == "open"]
222
+ genome = processing.processing.genome.value
223
+
224
+ if dataset is not None:
225
+ pin = processing.dataset
226
+ if pin is not None and pin.dataset_hash != dataset.provenance.dataset_hash:
227
+ blockers.append(
228
+ Blocker(
229
+ id="blk-pin-mismatch",
230
+ code=BlockerCode.DATASET_PIN_MISMATCH,
231
+ message=(
232
+ f"processing manifest {processing.processing_id!r} is pinned to dataset "
233
+ f"{pin.dataset_hash[:12]}…, not {dataset.provenance.dataset_hash[:12]}…."
234
+ ),
235
+ remedy=(
236
+ "Run `seqforge processing new` against this dataset, or drop the pin to make "
237
+ "it a portable template."
238
+ ),
239
+ subject=BlockerSubject(kind="field", ref="dataset.dataset_hash"),
240
+ )
241
+ )
242
+ organism = dataset.experiment.organism.value
243
+ if genome.ncbi_taxid is not None and genome.ncbi_taxid != organism:
244
+ blockers.append(
245
+ Blocker(
246
+ id="blk-genome-organism",
247
+ code=BlockerCode.GENOME_ORGANISM_MISMATCH,
248
+ message=(
249
+ f"processing selects assembly {genome.assembly!r} (taxid "
250
+ f"{genome.ncbi_taxid}), but the dataset's organism is taxid {organism}."
251
+ ),
252
+ remedy=(
253
+ f"Pick an assembly for taxid {organism}, or correct experiment.organism. A "
254
+ "wrong-but-valid assembly aligns and exits 0 — nothing downstream catches it."
255
+ ),
256
+ subject=BlockerSubject(kind="field", ref="processing.genome.assembly"),
257
+ )
258
+ )
259
+
260
+ return ValidationReport(
261
+ ok=not blockers and not open_conflicts,
262
+ blockers=blockers,
263
+ conflicts=open_conflicts,
264
+ warnings=[],
265
+ )
266
+
267
+
268
+ def exit_code_for_report(report: ValidationReport) -> int:
269
+ """Uniform contract: 3 BLOCKED (a hard Blocker), 4 NEEDS_HUMAN (an open Conflict), else 0."""
270
+ if report.blockers:
271
+ return 3
272
+ if report.conflicts:
273
+ return 4
274
+ return 0
@@ -0,0 +1,268 @@
1
+ """Pydantic v2 models — the single source of truth.
2
+
3
+ The manifest is **two** artifacts with two lifetimes: :class:`DatasetManifest` is what the data
4
+ *is* (immutable, one per dataset), :class:`ProcessingManifest` is what to *do* with it (many per
5
+ dataset). Their JSON Schemas feed validation and docs; the only LLM-facing schemas are
6
+ :class:`AssertionDraft` and the arbitration pair — the processing manifest is deliberately **not**
7
+ among them. The LLM emits ``AssertionDraft``; code composes the processing manifest from verified
8
+ assertions plus policy. That boundary is what keeps emit-data-never-code alive.
9
+
10
+ :func:`export_schema` dumps any model's JSON Schema (the ``seqforge schema export`` backend).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from pydantic import BaseModel
18
+
19
+ from .assertion import (
20
+ Assertion,
21
+ AssertionDraft,
22
+ ExtractorProvenance,
23
+ SourceSpan,
24
+ )
25
+ from .base import (
26
+ Accession,
27
+ AssayTerm,
28
+ Basis,
29
+ ChemistryId,
30
+ Confidence,
31
+ Evidenced,
32
+ LocalPath,
33
+ NcbiTaxid,
34
+ Rung,
35
+ Sha256,
36
+ Uri,
37
+ )
38
+ from .blocker import (
39
+ Blocker,
40
+ BlockerCode,
41
+ BlockerSubject,
42
+ ValidationWarning,
43
+ )
44
+ from .conflict import (
45
+ Conflict,
46
+ ConflictPosition,
47
+ Decidable,
48
+ Resolution,
49
+ )
50
+ from .dataset import (
51
+ AssayLabel,
52
+ DatasetManifest,
53
+ DatasetProvenance,
54
+ EvidencedReadLayout,
55
+ ExperimentSection,
56
+ FileInventoryItem,
57
+ LibrarySection,
58
+ Onlist,
59
+ ReadDef,
60
+ ReadElement,
61
+ ReadLayout,
62
+ SampleGroup,
63
+ Study,
64
+ )
65
+ from .evidenced import (
66
+ EvidencedAccessionList,
67
+ EvidencedAssay,
68
+ EvidencedBool,
69
+ EvidencedChemistrySet,
70
+ EvidencedStr,
71
+ EvidencedTaxid,
72
+ )
73
+ from .observation import (
74
+ ConstantSegment,
75
+ CycleComposition,
76
+ FileIdentity,
77
+ GzipIntegrity,
78
+ HomopolymerSegment,
79
+ Observation,
80
+ ProbeProvenance,
81
+ RandomSegment,
82
+ ReadLengthProfile,
83
+ ReadNameGrammar,
84
+ WindowDistinctRatio,
85
+ )
86
+ from .processing import (
87
+ BulkQuant,
88
+ DatasetPin,
89
+ EvidencedGenome,
90
+ EvidencedQuantification,
91
+ EvidencedRuntimeEnv,
92
+ GenomeRef,
93
+ ProcessingManifest,
94
+ ProcessingProvenance,
95
+ ProcessingSection,
96
+ Quantification,
97
+ ResourceHints,
98
+ RuntimeEnv,
99
+ SoloFeature,
100
+ SoloQuant,
101
+ )
102
+ from .resolve import (
103
+ ArbitrationRequest,
104
+ ArbitrationResponse,
105
+ Candidate,
106
+ ComposeResult,
107
+ Decision,
108
+ EvalReport,
109
+ ModuleSelection,
110
+ Question,
111
+ ResolveResult,
112
+ RoleAssignment,
113
+ RunResult,
114
+ TechScore,
115
+ ValidationReport,
116
+ )
117
+
118
+ #: Every top-level model that ``seqforge schema export`` can dump, keyed by class name.
119
+ SCHEMA_MODELS: dict[str, type[BaseModel]] = {
120
+ m.__name__: m
121
+ for m in (
122
+ # observation
123
+ Observation,
124
+ # harvest
125
+ AssertionDraft,
126
+ Assertion,
127
+ # resolve / conflict / blocker
128
+ Conflict,
129
+ Blocker,
130
+ ValidationWarning,
131
+ Candidate,
132
+ Question,
133
+ Decision,
134
+ ResolveResult,
135
+ ArbitrationRequest,
136
+ ArbitrationResponse,
137
+ ValidationReport,
138
+ # compile / manifest — TWO artifacts, two lifetimes
139
+ DatasetManifest,
140
+ ProcessingManifest,
141
+ ComposeResult,
142
+ RunResult,
143
+ EvalReport,
144
+ )
145
+ }
146
+
147
+ #: Models the LLM produces or consumes — their exported schema must stay inside the provider subset.
148
+ LLM_FACING: frozenset[str] = frozenset(
149
+ {"AssertionDraft", "ArbitrationRequest", "ArbitrationResponse"}
150
+ )
151
+
152
+
153
+ def export_schema(model_name: str) -> dict[str, Any]:
154
+ """Return the JSON Schema for one model by class name.
155
+
156
+ Raises
157
+ ------
158
+ KeyError
159
+ If ``model_name`` is not a known exportable model.
160
+ """
161
+ try:
162
+ model = SCHEMA_MODELS[model_name]
163
+ except KeyError as exc:
164
+ known = ", ".join(sorted(SCHEMA_MODELS))
165
+ raise KeyError(f"unknown model {model_name!r}; known models: {known}") from exc
166
+ return model.model_json_schema(ref_template="#/$defs/{model}")
167
+
168
+
169
+ def export_all() -> dict[str, dict[str, Any]]:
170
+ """Return the JSON Schema for every exportable model, keyed by class name."""
171
+ return {name: export_schema(name) for name in SCHEMA_MODELS}
172
+
173
+
174
+ __all__ = [
175
+ # export machinery
176
+ "SCHEMA_MODELS",
177
+ "LLM_FACING",
178
+ "export_schema",
179
+ "export_all",
180
+ # base
181
+ "Evidenced",
182
+ "EvidencedStr",
183
+ "EvidencedBool",
184
+ "EvidencedTaxid",
185
+ "EvidencedAssay",
186
+ "EvidencedChemistrySet",
187
+ "EvidencedAccessionList",
188
+ "EvidencedReadLayout",
189
+ "EvidencedGenome",
190
+ "EvidencedRuntimeEnv",
191
+ "EvidencedQuantification",
192
+ "Basis",
193
+ "Sha256",
194
+ "Uri",
195
+ "LocalPath",
196
+ "AssayTerm",
197
+ "NcbiTaxid",
198
+ "Accession",
199
+ "ChemistryId",
200
+ "Confidence",
201
+ "Rung",
202
+ # observation
203
+ "Observation",
204
+ "FileIdentity",
205
+ "ProbeProvenance",
206
+ "CycleComposition",
207
+ "ConstantSegment",
208
+ "RandomSegment",
209
+ "HomopolymerSegment",
210
+ "ReadLengthProfile",
211
+ "WindowDistinctRatio",
212
+ "ReadNameGrammar",
213
+ "GzipIntegrity",
214
+ # assertion
215
+ "SourceSpan",
216
+ "AssertionDraft",
217
+ "Assertion",
218
+ "ExtractorProvenance",
219
+ # conflict
220
+ "Conflict",
221
+ "ConflictPosition",
222
+ "Resolution",
223
+ "Decidable",
224
+ # blocker
225
+ "Blocker",
226
+ "BlockerCode",
227
+ "BlockerSubject",
228
+ "ValidationWarning",
229
+ # dataset manifest — the IR: what the data IS (immutable, one per dataset)
230
+ "DatasetManifest",
231
+ "DatasetProvenance",
232
+ "LibrarySection",
233
+ "ExperimentSection",
234
+ "ReadLayout",
235
+ "ReadDef",
236
+ "ReadElement",
237
+ "Onlist",
238
+ "FileInventoryItem",
239
+ "SampleGroup",
240
+ "AssayLabel",
241
+ "Study",
242
+ # processing manifest — the flags: what to DO with it (many per dataset)
243
+ "ProcessingManifest",
244
+ "ProcessingProvenance",
245
+ "ProcessingSection",
246
+ "DatasetPin",
247
+ "GenomeRef",
248
+ "RuntimeEnv",
249
+ "ResourceHints",
250
+ "SoloFeature",
251
+ "SoloQuant",
252
+ "BulkQuant",
253
+ "Quantification",
254
+ # resolve outputs
255
+ "TechScore",
256
+ "RoleAssignment",
257
+ "Candidate",
258
+ "Question",
259
+ "Decision",
260
+ "ResolveResult",
261
+ "ArbitrationRequest",
262
+ "ArbitrationResponse",
263
+ "ValidationReport",
264
+ "ModuleSelection",
265
+ "ComposeResult",
266
+ "RunResult",
267
+ "EvalReport",
268
+ ]
@@ -0,0 +1,68 @@
1
+ """``Assertion`` — the LLM's structured output (job a), span-verified by deterministic code.
2
+
3
+ The LLM emits an :class:`AssertionDraft` (``field``, ``value``, a ``quote``), never character
4
+ offsets — LLMs cannot count them. Deterministic code searches the normalized document for the quote,
5
+ computes offsets, and sets the two verification flags, so a hallucinated or mis-attributed claim
6
+ fails closed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pydantic import BaseModel
12
+
13
+ from .base import Confidence
14
+
15
+
16
+ class SourceSpan(BaseModel):
17
+ """Exact, greppable provenance for one claim. Offsets are COMPUTED by code, not the LLM.
18
+
19
+ ``page`` is the same kind of field as ``char_start``/``char_end``: code-owned, computed by
20
+ ``verify`` from the offset against the document's page index, and the LLM's value (if any) is
21
+ discarded. It is the 1-indexed physical page of a PDF, and ``None`` for an unpaged source (a
22
+ ``.txt``/``.xlsx`` or an archive record) — so a citation can say "p.4" only when that is a real,
23
+ checkable location.
24
+ """
25
+
26
+ doc_sha256: str
27
+ quote: str
28
+ context: str | None = None
29
+ char_start: int | None = None
30
+ char_end: int | None = None
31
+ page: int | None = None
32
+
33
+
34
+ class AssertionDraft(BaseModel):
35
+ """The ONLY LLM structured-output surface for harvest (job a).
36
+
37
+ Kept trivially simple — no unions, no offsets, ``value`` is a plain string — so it stays inside
38
+ the provider strict-schema subset.
39
+ """
40
+
41
+ field: str
42
+ value: str
43
+ span: SourceSpan
44
+ llm_confidence: Confidence
45
+
46
+
47
+ class ExtractorProvenance(BaseModel):
48
+ """Which model + prompt produced an assertion (makes a harvest reproducible and blamable)."""
49
+
50
+ model_id: str
51
+ prompt_version: str
52
+
53
+
54
+ class Assertion(BaseModel):
55
+ """A stored, code-composed assertion. Both verification flags are code-owned (fail-closed).
56
+
57
+ ``span_verified`` catches fabricated provenance; ``entailment_ok`` catches a real quote
58
+ mis-attached to a wrong value. Both must hold before an assertion flows into ``manifest fill``.
59
+ """
60
+
61
+ id: str
62
+ field: str
63
+ value: str
64
+ span: SourceSpan
65
+ span_verified: bool = False
66
+ entailment_ok: bool = False
67
+ llm_confidence: Confidence
68
+ extractor: ExtractorProvenance