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