seqforge 2026.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. seqforge/__init__.py +16 -0
  2. seqforge/cli/__init__.py +38 -0
  3. seqforge/cli/__main__.py +8 -0
  4. seqforge/cli/_common.py +105 -0
  5. seqforge/cli/compose.py +119 -0
  6. seqforge/cli/eval.py +103 -0
  7. seqforge/cli/harvest.py +417 -0
  8. seqforge/cli/hook.py +247 -0
  9. seqforge/cli/io.py +502 -0
  10. seqforge/cli/kb.py +348 -0
  11. seqforge/cli/manifest.py +536 -0
  12. seqforge/cli/probe.py +43 -0
  13. seqforge/cli/processing.py +192 -0
  14. seqforge/cli/project.py +52 -0
  15. seqforge/cli/resolve.py +55 -0
  16. seqforge/cli/root.py +66 -0
  17. seqforge/cli/run.py +463 -0
  18. seqforge/cli/schema.py +41 -0
  19. seqforge/compose/__init__.py +28 -0
  20. seqforge/compose/core.py +515 -0
  21. seqforge/compose/gates.py +113 -0
  22. seqforge/compose/params.py +447 -0
  23. seqforge/e2e.py +1926 -0
  24. seqforge/evals/__init__.py +78 -0
  25. seqforge/evals/case.py +382 -0
  26. seqforge/evals/grade.py +300 -0
  27. seqforge/evals/run.py +420 -0
  28. seqforge/harvest/__init__.py +121 -0
  29. seqforge/harvest/extract.py +319 -0
  30. seqforge/harvest/fields.py +212 -0
  31. seqforge/harvest/normalize.py +537 -0
  32. seqforge/harvest/prep.py +41 -0
  33. seqforge/harvest/providers.py +321 -0
  34. seqforge/harvest/verify.py +251 -0
  35. seqforge/hooks/__init__.py +33 -0
  36. seqforge/hooks/guards.py +214 -0
  37. seqforge/io/__init__.py +61 -0
  38. seqforge/io/archive.py +450 -0
  39. seqforge/io/attributes.py +190 -0
  40. seqforge/io/biosample/attributes.json +6341 -0
  41. seqforge/io/efo/labels.json +55 -0
  42. seqforge/io/efo.py +138 -0
  43. seqforge/io/onlist.py +661 -0
  44. seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
  45. seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
  46. seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
  47. seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
  48. seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
  49. seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
  50. seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
  51. seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
  52. seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
  53. seqforge/io/onlists/index.json +74 -0
  54. seqforge/io/remote.py +659 -0
  55. seqforge/io/taxonomy.py +194 -0
  56. seqforge/kb/__init__.py +62 -0
  57. seqforge/kb/anchor.py +169 -0
  58. seqforge/kb/generate.py +147 -0
  59. seqforge/kb/loader.py +152 -0
  60. seqforge/kb/roundtrip.py +112 -0
  61. seqforge/kb/schema.py +422 -0
  62. seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
  63. seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
  64. seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
  65. seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
  66. seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
  67. seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
  68. seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
  69. seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
  70. seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
  71. seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
  72. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
  73. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
  74. seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
  75. seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
  76. seqforge/kb/specs/splitseq/README.md +51 -0
  77. seqforge/kb/specs/splitseq/spec.yaml +157 -0
  78. seqforge/manifest/__init__.py +61 -0
  79. seqforge/manifest/fill.py +531 -0
  80. seqforge/manifest/hash.py +77 -0
  81. seqforge/manifest/instruct.py +114 -0
  82. seqforge/manifest/policy.py +409 -0
  83. seqforge/manifest/validate.py +274 -0
  84. seqforge/models/__init__.py +268 -0
  85. seqforge/models/assertion.py +68 -0
  86. seqforge/models/base.py +100 -0
  87. seqforge/models/blocker.py +71 -0
  88. seqforge/models/conflict.py +47 -0
  89. seqforge/models/dataset.py +320 -0
  90. seqforge/models/evidenced.py +54 -0
  91. seqforge/models/observation.py +157 -0
  92. seqforge/models/processing.py +231 -0
  93. seqforge/models/records.py +145 -0
  94. seqforge/models/resolve.py +216 -0
  95. seqforge/probe/__init__.py +46 -0
  96. seqforge/probe/core.py +232 -0
  97. seqforge/probe/signals.py +250 -0
  98. seqforge/probe/streaming.py +118 -0
  99. seqforge/project.py +177 -0
  100. seqforge/py.typed +0 -0
  101. seqforge/resolve/__init__.py +98 -0
  102. seqforge/resolve/assign.py +204 -0
  103. seqforge/resolve/cache.py +119 -0
  104. seqforge/resolve/confuse.py +215 -0
  105. seqforge/resolve/engine.py +646 -0
  106. seqforge/resolve/escalate.py +668 -0
  107. seqforge/resolve/evaluators.py +306 -0
  108. seqforge/resolve/geometry.py +89 -0
  109. seqforge/resolve/group.py +85 -0
  110. seqforge/resolve/records.py +550 -0
  111. seqforge/resolve/scoring.py +373 -0
  112. seqforge/resolve/window.py +206 -0
  113. seqforge/workflows/__init__.py +234 -0
  114. seqforge/workflows/cram.py +117 -0
  115. seqforge/workflows/h5ad.py +368 -0
  116. seqforge/workflows/map/star.smk +101 -0
  117. seqforge/workflows/map/starsolo.smk +360 -0
  118. seqforge/workflows/qc.py +157 -0
  119. seqforge/workspace.py +125 -0
  120. seqforge-2026.7.1.dist-info/METADATA +125 -0
  121. seqforge-2026.7.1.dist-info/RECORD +124 -0
  122. seqforge-2026.7.1.dist-info/WHEEL +4 -0
  123. seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
  124. seqforge-2026.7.1.dist-info/licenses/LICENSE +21 -0
seqforge/cli/run.py ADDED
@@ -0,0 +1,463 @@
1
+ """`seqforge run` (alias `compile`) -- chain the whole diagram in one headless pass.
2
+
3
+ FASTQ + metadata in, manifest.yaml + Snakefile out; stops at the first refusal and folds every
4
+ stage into one JSON summary. Adds no authority -- it calls the same deterministic stage bodies the
5
+ individual verbs run, per assay.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any, cast
13
+
14
+ import typer
15
+ import yaml
16
+ from pydantic import ValidationError
17
+
18
+ from .. import __version__
19
+ from ..compose import ComposeError, compose
20
+ from ..io import default_registry
21
+ from ..kb import load_spec
22
+ from ..manifest import (
23
+ PolicyError,
24
+ ProcessingInputs,
25
+ exit_code_for_report,
26
+ fill_processing,
27
+ validate_processing,
28
+ )
29
+ from ..workspace import logs_dir, readable, records_dir, state_dir
30
+ from ._common import _auto_cpus, _load_manifest
31
+ from .harvest import PdfBackendChoice, _harvest_extract_pipeline
32
+ from .manifest import _fill_manifest_pipeline
33
+ from .processing import _instructions_from, _prep_type_from
34
+ from .root import app
35
+
36
+ if TYPE_CHECKING:
37
+ from ..harvest.normalize import PdfBackend
38
+ from ..models.records import ArchiveRecordSet
39
+
40
+
41
+ def _common_fastq_root(files: list[Path]) -> Path | None:
42
+ """The directory the FASTQs' machine-independent URIs are relative to: ``os.path.commonpath`` of
43
+ their resolved parent dirs, mirroring :func:`seqforge.manifest.fill.dataset_uris` exactly so that
44
+ ``fastq_dir / uri`` reconstructs each real path. ``None`` when they share no common root (different
45
+ filesystems) — ``dataset_uris``'s basename fallback — and the caller then leaves ``--fastq-dir`` unset.
46
+ """
47
+ import os
48
+
49
+ try:
50
+ return Path(os.path.commonpath([str(f.parent.resolve()) for f in files]))
51
+ except ValueError: # no common root (e.g. different drives)
52
+ return None
53
+
54
+
55
+ def _run_records_stage(
56
+ accession: list[str], records_path: Path | None, *, workspace: Path, offline: bool
57
+ ) -> tuple[ArchiveRecordSet | None, Path | None]:
58
+ """Fetch + cache the archive records for `run`, returning (the set, a file harvest can render from).
59
+
60
+ Where `manifest fill` fetches into memory, `run` writes each record set under `seqforge/records/`
61
+ — the same place `io records` caches — because `run` is the convenience path and every
62
+ stage leaves a resumable artifact. Harvest renders record documents from a *file*, so `run` hands
63
+ the same file to both harvest and fill. `--offline` with an accession refuses, for the reason fill
64
+ does: you asked for those facts, and the manifest is content-addressed and permanent.
65
+ """
66
+ import hashlib
67
+
68
+ from ..io.archive import fetch_records
69
+ from ..io.remote import RemoteError
70
+ from ..models.records import ArchiveRecordSet
71
+
72
+ if records_path is not None:
73
+ return ArchiveRecordSet.model_validate_json(records_path.read_text()), records_path
74
+ if not accession:
75
+ return None, None
76
+ if offline:
77
+ raise RemoteError(
78
+ f"--accession {', '.join(accession)} needs the archive, and --offline forbids it. "
79
+ f"Fetch once with `seqforge io records {accession[0]}` and pass --records, or drop "
80
+ f"--accession to compile with no sample facts."
81
+ )
82
+ outdir = records_dir(workspace)
83
+ outdir.mkdir(parents=True, exist_ok=True)
84
+ merged: list[Any] = []
85
+ per_accession: list[Path] = []
86
+ for acc in accession:
87
+ record_set = fetch_records(acc)
88
+ target = outdir / f"{acc}.json"
89
+ target.write_text(json.dumps(record_set.model_dump(mode="json"), indent=2))
90
+ per_accession.append(target)
91
+ merged.extend(record_set.records)
92
+ if len(accession) == 1:
93
+ return ArchiveRecordSet(source="ncbi-sra+biosample", query=accession[0], records=merged), (
94
+ per_accession[0]
95
+ )
96
+ # Two accessions render one dataset: harvest needs them in a single document set, so write a
97
+ # combined file keyed by the accession list (the per-accession caches stay, for `io records`).
98
+ combined = ArchiveRecordSet(
99
+ source="ncbi-sra+biosample", query=", ".join(accession), records=merged
100
+ )
101
+ tag = hashlib.sha256(", ".join(sorted(accession)).encode()).hexdigest()
102
+ combined_path = outdir / (readable("combined", tag) + ".json")
103
+ combined_path.write_text(json.dumps(combined.model_dump(mode="json"), indent=2))
104
+ return combined, combined_path
105
+
106
+
107
+ def _run_finish(stages: dict[str, object], code: int) -> None:
108
+ """Emit the single `run` summary and exit with the pipeline's code. Always raises."""
109
+ summary: dict[str, object] = {"ok": code == 0, "exit_code": code, "stages": stages}
110
+ harvest = stages.get("harvest")
111
+ if isinstance(harvest, dict) and isinstance(harvest.get("usage"), dict):
112
+ # The token cost of understanding the prose, surfaced at the top: the full per-document ledger
113
+ # is on disk (seqforge/usage.json) and in the harvest stage; this is the total a reader wants.
114
+ summary["llm_usage"] = harvest["usage"]
115
+ if code == 0:
116
+ assays = stages.get("assays")
117
+ if isinstance(assays, list): # multi-assay: one manifest + Snakefile per assay
118
+ summary["assays"] = [
119
+ {
120
+ "chemistry": a.get("chemistry"),
121
+ "manifest": a.get("manifest"),
122
+ "snakefile": cast(dict, a.get("compose", {})).get("snakefile_path"),
123
+ }
124
+ for a in assays
125
+ ]
126
+ else:
127
+ summary["manifest"] = cast(dict, stages.get("manifest", {})).get("manifest")
128
+ summary["processing"] = cast(dict, stages.get("processing", {})).get("processing")
129
+ summary["snakefile"] = cast(dict, stages.get("compose", {})).get("snakefile_path")
130
+ typer.echo(json.dumps(summary, indent=2))
131
+ raise typer.Exit(code)
132
+
133
+
134
+ def _harvest_halts_run(payload: dict[str, object] | str, code: int) -> bool:
135
+ """Does a harvest result stop the one-pass, or is it surfaced and stepped past?
136
+
137
+ A **conflict** (two instructions disagreeing on a `processing.*` field) or an unavailable provider
138
+ halts `run` — the first decides a value nothing else can, the second means the LLM stage could not
139
+ run at all. A **rejected reference claim** does not: it never entered `assertions.json`, so the
140
+ manifest is built from the accepted claims and the bytes, and chemistry comes from bytes anyway. It
141
+ is reported in the summary (`needs_review` + the `rejected` list), which is what we ask for — "not a
142
+ silent drop" — while letting a paper whose prose the span-checker cannot formally tie to a KB id
143
+ still compile. Standalone `harvest extract` keeps exiting 4 on a rejection; only `run` steps past.
144
+ """
145
+ if code == 0:
146
+ return False
147
+ if code == 4 and isinstance(payload, dict) and not (payload.get("conflicts") or []):
148
+ return False
149
+ return True
150
+
151
+
152
+ def _process_and_compose(
153
+ *,
154
+ manifest: Any,
155
+ state: Path,
156
+ subdir: str | None,
157
+ workspace: Path,
158
+ assembly: str | None,
159
+ annotation: str | None,
160
+ assertions_path: Path | None,
161
+ processing_id: str,
162
+ offline: bool,
163
+ onlist_dir: Path | None,
164
+ outdir: str,
165
+ fastq_dir: Path | None,
166
+ sif_dir: Path | None,
167
+ ) -> tuple[dict[str, object], int]:
168
+ """Stages 4-5 for ONE assay: the flags (``processing.yaml``) + the deliverable (the Snakefile).
169
+
170
+ Writes ``processing.yaml`` under ``state`` and the pipeline under ``seqforge/<subdir>/pipeline/``
171
+ (the flat ``seqforge/pipeline/`` when ``subdir`` is None). Returns ``(summary, exit_code)``; the
172
+ caller folds it into the run summary. Same code the single-assay path always ran, per assay.
173
+ """
174
+ summary: dict[str, object] = {}
175
+ try:
176
+ instructions = _instructions_from(assertions_path)
177
+ prep_type = _prep_type_from(assertions_path)
178
+ except (OSError, ValueError, ValidationError) as exc:
179
+ return {"processing": {"error": str(exc)}}, 2
180
+ try:
181
+ processing, warnings = fill_processing(
182
+ spec=load_spec(manifest.library.chemistry.value[0]),
183
+ dataset=manifest,
184
+ processing=ProcessingInputs(assembly=assembly, annotation_name=annotation),
185
+ instructions=instructions,
186
+ prep_type=prep_type,
187
+ processing_id=processing_id,
188
+ pin=True,
189
+ seqforge_version=__version__,
190
+ )
191
+ except (PolicyError, ValidationError) as exc:
192
+ # The one real decision with no safe default; fill_processing's message already names the
193
+ # organism and how to supply a genome, so pass it through.
194
+ return {"processing": {"error": str(exc)}}, 2
195
+ p_report = validate_processing(processing, dataset=manifest)
196
+ proc_path = state / "processing.yaml"
197
+ proc_path.write_text(yaml.safe_dump(processing.model_dump(mode="json"), sort_keys=True))
198
+ summary["processing"] = {
199
+ "processing": str(proc_path),
200
+ "report": p_report.model_dump(mode="json"),
201
+ "warnings": [w.model_dump(mode="json") for w in warnings],
202
+ }
203
+ if not p_report.ok:
204
+ return summary, exit_code_for_report(p_report)
205
+
206
+ try:
207
+ result = compose(
208
+ manifest,
209
+ processing,
210
+ registry=default_registry(offline=offline, local_dir=onlist_dir),
211
+ workspace=workspace,
212
+ outdir=outdir,
213
+ fastq_dir=fastq_dir,
214
+ sif_dir=sif_dir,
215
+ subdir=subdir,
216
+ )
217
+ except ComposeError as exc:
218
+ summary["compose"] = {"error": str(exc)}
219
+ return summary, 3
220
+ summary["compose"] = result.model_dump(mode="json")
221
+ return summary, (3 if any(v == "fail" for v in result.gate.values()) else 0)
222
+
223
+
224
+ @app.command("run")
225
+ def run_cmd(
226
+ files: list[Path] = typer.Argument(..., help="The dataset's FASTQ .gz files."),
227
+ accession: list[str] = typer.Option(
228
+ [], "--accession", help="Accession(s): the archive's per-sample records. Optional."
229
+ ),
230
+ records_path: Path | None = typer.Option(
231
+ None, "--records", help="An already-fetched record set, instead of fetching now."
232
+ ),
233
+ doc: list[Path] = typer.Option(
234
+ [],
235
+ "--doc",
236
+ help="Reference document(s) — a paper .pdf/.txt/.md or a supplementary .xlsx — to read for claims.",
237
+ ),
238
+ instruction: list[Path] = typer.Option(
239
+ [],
240
+ "--instruction",
241
+ help="Document(s) authored FOR seqforge; only these may set processing.*.",
242
+ ),
243
+ pdf_backend: PdfBackendChoice = typer.Option(
244
+ PdfBackendChoice.pymupdf,
245
+ "--pdf-backend",
246
+ help="PDF text extractor: pymupdf (default, AGPL, reads more PDFs) | pypdf (BSD fallback).",
247
+ ),
248
+ organism: str | None = typer.Option(
249
+ None, "--organism", help="NCBI taxid or name. Optional when --accession declares it."
250
+ ),
251
+ assert_chemistry: str | None = typer.Option(
252
+ None,
253
+ "--assert-chemistry",
254
+ help="Force a chemistry KB id (e.g. 10x-3p-gex-v3) as the score hypothesis, outranking any "
255
+ "harvested claim. Breaks a genuine byte tie (v2/v3); still only a selector, never evidence.",
256
+ ),
257
+ assembly: str | None = typer.Option(
258
+ None,
259
+ "--assembly",
260
+ help="Genome: liulab-genome UCSC assembly id (e.g. ce11). The one decision.",
261
+ ),
262
+ annotation: str | None = typer.Option(
263
+ None, "--annotation", help="Registered GTF name (e.g. WS298)."
264
+ ),
265
+ no_llm: bool = typer.Option(
266
+ False, "--no-llm", help="Skip the one LLM stage; fully deterministic. Ignores --doc."
267
+ ),
268
+ provider: str | None = typer.Option(
269
+ None, "--provider", help="anthropic | deepseek | openai-compatible (default: auto-detect)."
270
+ ),
271
+ model: str | None = typer.Option(None, "--model", help="Override the extraction model."),
272
+ processing_id: str = typer.Option("default", "--id", help="Human slug for the recipe."),
273
+ fastq_dir: Path | None = typer.Option(
274
+ None, "--fastq-dir", help="Where this machine keeps the FASTQs (for units.tsv)."
275
+ ),
276
+ onlist_dir: Path | None = typer.Option(
277
+ None,
278
+ "--onlist-dir",
279
+ envvar="SEQFORGE_ONLIST_DIR",
280
+ help="Directory of downloaded barcode whitelists (<name>.txt.gz).",
281
+ ),
282
+ sif_dir: Path | None = typer.Option(
283
+ None,
284
+ "--sif-dir",
285
+ envvar="LIU_LAB_PACKAGES",
286
+ help="Directory of prebuilt liulab-runtime images (liulab-runtime_<env>.sif).",
287
+ ),
288
+ outdir: str = typer.Option("results", help="Pipeline output directory (written into config)."),
289
+ offline: bool = typer.Option(False, "--offline", help="Never reach the network."),
290
+ cpus: int = typer.Option(
291
+ 0, "--cpus", help="Parallel probe workers. 0 = auto (min(8, CPUs)); 1 = sequential."
292
+ ),
293
+ workspace: Path = typer.Option(
294
+ Path("."), "-C", "--workspace", help="Root for seqforge/ state."
295
+ ),
296
+ ) -> None:
297
+ """One pass: FASTQ + metadata -> manifest.yaml AND a runnable Snakefile.
298
+
299
+ Chains the deterministic verbs — records, harvest, manifest fill, processing new, compose — in
300
+ order, stops at the first refusal, and emits ONE JSON summary keyed by stage. It decides nothing
301
+ itself: chemistry, read roles and organism come from the same code the individual verbs run, and
302
+ the exit-code contract is preserved (3 BLOCKED, 4 NEEDS_HUMAN). Re-running is resumable through
303
+ each stage's own content-addressed cache; there is no --resume flag.
304
+
305
+ The genome is the one real decision and has no safe default: pass --assembly/--annotation, or state
306
+ it in an --instruction document. Everything else is optional — no accession, no paper, and
307
+ --no-llm each give a quieter, still-true manifest. `harvest extract` is the sole LLM touchpoint and
308
+ calls its own provider (DEEPSEEK_API_KEY / ANTHROPIC_API_KEY), which is why --no-llm exists.
309
+ """
310
+ from ..io.remote import RemoteError
311
+
312
+ # `compose` joins each machine-independent URI (a path relative to the FASTQs' COMMON ROOT, per
313
+ # `dataset_uris`) onto `--fastq-dir` to build units.tsv. So `--fastq-dir` must BE that common root,
314
+ # not just any ancestor: a dataset whose reads all sit in one accession subdir (`SRX…/…_1.fastq.gz`)
315
+ # makes the common root that subdir, and pointing `--fastq-dir` one level up would drop it and
316
+ # yield units.tsv paths that do not exist (a wiring-gate failure, seen live on GSE274290). `run`
317
+ # already holds every input path, so default `--fastq-dir` to the computed common root rather than
318
+ # make a headless caller know this contract. An explicit flag still wins.
319
+ if fastq_dir is None and files:
320
+ fastq_dir = _common_fastq_root(files)
321
+
322
+ stages: dict[str, object] = {}
323
+
324
+ # 1) Archive records (optional): fetch + cache, or refuse offline.
325
+ records: ArchiveRecordSet | None = None
326
+ records_file: Path | None = None
327
+ try:
328
+ records, records_file = _run_records_stage(
329
+ accession, records_path, workspace=workspace, offline=offline
330
+ )
331
+ except RemoteError as exc:
332
+ stages["records"] = {"error": "records_unavailable", "detail": str(exc)}
333
+ _run_finish(stages, 3)
334
+ if records is not None:
335
+ stages["records"] = {
336
+ "source": records.source,
337
+ "n": {
338
+ level: len(records.at(level)) # type: ignore[arg-type]
339
+ for level in ("project", "sample", "experiment", "run")
340
+ },
341
+ }
342
+
343
+ # 2) Harvest — the one LLM stage. Skipped by --no-llm or when there is no prose to read.
344
+ assertions_path: Path | None = None
345
+ if no_llm and (doc or instruction):
346
+ stages["harvest"] = {"skipped": "--no-llm: documents were not read"}
347
+ elif not no_llm and (doc or instruction):
348
+ harvested = _harvest_extract_pipeline(
349
+ docs=doc,
350
+ instruction=instruction,
351
+ records_path=records_file,
352
+ provider=provider,
353
+ model=model,
354
+ verify=True,
355
+ workspace=workspace,
356
+ pdf_backend=cast("PdfBackend", pdf_backend.value),
357
+ )
358
+ stages["harvest"] = (
359
+ harvested.payload
360
+ if isinstance(harvested.payload, dict)
361
+ else {"error": harvested.payload}
362
+ )
363
+ if _harvest_halts_run(harvested.payload, harvested.code):
364
+ _run_finish(stages, harvested.code)
365
+ if harvested.code == 4:
366
+ # rejected reference claims survived the halt check: surface them, do not stop (see
367
+ # `_harvest_halts_run`). They were dropped from assertions.json already; this is the "not
368
+ # a silent drop" we ask for, in a field a headless caller still sees.
369
+ cast(dict, stages["harvest"])["needs_review"] = (
370
+ "prose claims failed span-verification and were dropped (see 'rejected'); the manifest "
371
+ "was built from the accepted claims and the bytes"
372
+ )
373
+ assertions_path = logs_dir(workspace) / "assertions.json"
374
+
375
+ # 3) The IR: what the data IS. Probe + resolve + metadata, both resolvers, both able to refuse.
376
+ fill = _fill_manifest_pipeline(
377
+ files=files,
378
+ organism=organism,
379
+ records=records,
380
+ assertions=assertions_path,
381
+ offline=offline,
382
+ workspace=workspace,
383
+ cpus=_auto_cpus(cpus),
384
+ chemistry_override=assert_chemistry,
385
+ )
386
+ stages["manifest"] = fill.payload if isinstance(fill.payload, dict) else {"error": fill.payload}
387
+ if fill.code != 0:
388
+ _run_finish(stages, fill.code)
389
+
390
+ # A project is one assay (the flat, byte-identical layout) or several (one seqforge/<assay>/ each).
391
+ manifest_payload = cast(dict, stages["manifest"])
392
+ if "assays" in manifest_payload:
393
+ targets = [
394
+ (cast(str, a["chemistry"]), cast(str, a["assay_dir"]), Path(cast(str, a["manifest"])))
395
+ for a in cast(list, manifest_payload["assays"])
396
+ ]
397
+ else:
398
+ targets = [(None, None, Path(cast(str, manifest_payload["manifest"])))]
399
+
400
+ # 4-5) The flags + the deliverable, per assay. Each is a normal single-chemistry compile.
401
+ compiled: list[tuple[str | None, str, dict[str, object], int]] = []
402
+ assay_infos: list[dict[str, object]] = []
403
+ worst = 0
404
+ for chemistry, subdir, manifest_path in targets:
405
+ manifest = _load_manifest(manifest_path)
406
+ state = state_dir(workspace, subdir) if subdir else state_dir(workspace)
407
+ summary, code = _process_and_compose(
408
+ manifest=manifest,
409
+ state=state,
410
+ subdir=subdir,
411
+ workspace=workspace,
412
+ assembly=assembly,
413
+ annotation=annotation,
414
+ assertions_path=assertions_path,
415
+ processing_id=processing_id,
416
+ offline=offline,
417
+ onlist_dir=onlist_dir,
418
+ outdir=outdir,
419
+ fastq_dir=fastq_dir,
420
+ sif_dir=sif_dir,
421
+ )
422
+ worst = max(worst, code)
423
+ compiled.append((chemistry, str(manifest_path), summary, code))
424
+ assay_infos.append(
425
+ {
426
+ "chemistry": manifest.library.chemistry.value[0],
427
+ "subdir": subdir,
428
+ "n_samples": len(manifest.experiment.samples),
429
+ "manifest": str(manifest_path),
430
+ "snakefile": cast(dict, summary.get("compose", {})).get("snakefile_path"),
431
+ }
432
+ )
433
+
434
+ # The "one study" view over every assay: a flat sample table + an assay index, at the project top.
435
+ # Derived from the manifests (which all exist -- fill succeeded above), so it is written even if a
436
+ # downstream compose failed. See seqforge/project.py.
437
+ from ..project import write_project_views
438
+
439
+ tsv_path, project_path = write_project_views(workspace, assay_infos)
440
+ stages["project"] = {
441
+ "sample_metadata": str(tsv_path),
442
+ "project": str(project_path),
443
+ "n_assays": len(assay_infos),
444
+ }
445
+
446
+ if targets[0][0] is None: # single assay: flat stages, byte-identical to before
447
+ _, _, summary, code = compiled[0]
448
+ if "processing" in summary:
449
+ stages["processing"] = summary["processing"]
450
+ if "compose" in summary:
451
+ stages["compose"] = summary["compose"]
452
+ _run_finish(stages, code)
453
+ else: # multi-assay: one complete record per assay
454
+ stages["assays"] = [
455
+ {"chemistry": chem, "manifest": mpath, **summary}
456
+ for chem, mpath, summary, _ in compiled
457
+ ]
458
+ _run_finish(stages, worst)
459
+
460
+
461
+ app.command(
462
+ "compile", help="Alias for `run`: FASTQ + metadata -> manifest + Snakefile in one pass."
463
+ )(run_cmd)
seqforge/cli/schema.py ADDED
@@ -0,0 +1,41 @@
1
+ """`seqforge schema` -- export JSON Schema from the Pydantic models (the single source of truth)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import typer
8
+
9
+ from ..models import SCHEMA_MODELS, export_all, export_schema
10
+ from .root import schema_app
11
+
12
+
13
+ @schema_app.command("list")
14
+ def schema_list() -> None:
15
+ """List every model whose JSON Schema can be exported."""
16
+ for name in sorted(SCHEMA_MODELS):
17
+ typer.echo(name)
18
+
19
+
20
+ @schema_app.command("export")
21
+ def schema_export(
22
+ model: str | None = typer.Argument(
23
+ None, help="Model class name to export (e.g. Manifest). Omit with --all for every model."
24
+ ),
25
+ export_all_models: bool = typer.Option(
26
+ False, "--all", help="Export every model's schema as one JSON object."
27
+ ),
28
+ ) -> None:
29
+ """Dump one model's (or every model's) JSON Schema to stdout."""
30
+ if export_all_models:
31
+ typer.echo(json.dumps(export_all(), indent=2, sort_keys=True))
32
+ return
33
+ if model is None:
34
+ typer.echo("give a MODEL name or --all; see `seqforge schema list`", err=True)
35
+ raise typer.Exit(2)
36
+ try:
37
+ schema = export_schema(model)
38
+ except KeyError as exc:
39
+ typer.echo(str(exc), err=True)
40
+ raise typer.Exit(2) from exc
41
+ typer.echo(json.dumps(schema, indent=2, sort_keys=True))
@@ -0,0 +1,28 @@
1
+ """``compose`` — manifest -> pipeline configuration (emit data, never code).
2
+
3
+ A pure function of the manifest plus two versioned inputs recorded in provenance (the KB and the
4
+ hand-written workflow modules). It selects a module and emits ``config.yaml`` + ``units.tsv``; it
5
+ never writes rule source. The three-part gate (design §4.1) runs here: the deterministic **params**
6
+ assertions always, **wiring** (`snakemake -n`/`--lint`) and **e2e** (the count-matrix run) only when
7
+ their toolchain exists — otherwise ``skip``, never a silent ``pass``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .core import ComposeError, ComposePlan, compose, plan
13
+ from .gates import e2e_gate, wiring_gate
14
+ from .params import RECIPE_PARAM_KEYS, param_owners, params_gate, processing_params, render_param
15
+
16
+ __all__ = [
17
+ "compose",
18
+ "plan",
19
+ "ComposePlan",
20
+ "ComposeError",
21
+ "params_gate",
22
+ "param_owners",
23
+ "processing_params",
24
+ "RECIPE_PARAM_KEYS",
25
+ "render_param",
26
+ "wiring_gate",
27
+ "e2e_gate",
28
+ ]