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/manifest.py
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""`seqforge manifest` -- the DATASET manifest (what the data IS) plus the probe->resolve->partition
|
|
2
|
+
pipeline that fills it. `_fill_manifest_pipeline` is the stage body `seqforge run` chains.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from .. import __version__
|
|
15
|
+
from ..io import DEFAULT_REGISTRY
|
|
16
|
+
from ..io.taxonomy import TaxonomyUnavailable
|
|
17
|
+
from ..kb import list_spec_ids, load_spec
|
|
18
|
+
from ..manifest import (
|
|
19
|
+
FillError,
|
|
20
|
+
dataset_content_hash,
|
|
21
|
+
dataset_uris,
|
|
22
|
+
exit_code_for_report,
|
|
23
|
+
experiment_from_metadata,
|
|
24
|
+
fill_manifest,
|
|
25
|
+
validate_manifest,
|
|
26
|
+
)
|
|
27
|
+
from ..models.assertion import Assertion
|
|
28
|
+
from ..resolve import Hypothesis, resolve_runs
|
|
29
|
+
from ..workspace import legacy_state_dir, state_dir
|
|
30
|
+
from ._common import _auto_cpus, _emit, _load_manifest, _resolve_organism, _StageOut
|
|
31
|
+
from .root import manifest_app
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from ..models.records import ArchiveRecordSet
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@manifest_app.command("fill")
|
|
38
|
+
def manifest_fill(
|
|
39
|
+
files: list[Path] = typer.Argument(..., help="The dataset's FASTQ .gz files."),
|
|
40
|
+
organism: str | None = typer.Option(
|
|
41
|
+
None,
|
|
42
|
+
"--organism",
|
|
43
|
+
help="NCBI taxid (6239) or scientific name ('Caenorhabditis elegans'). Optional when "
|
|
44
|
+
"--accession is given: the archive record declares the organism. A flag beats the record.",
|
|
45
|
+
),
|
|
46
|
+
accession: list[str] = typer.Option(
|
|
47
|
+
[],
|
|
48
|
+
"--accession",
|
|
49
|
+
help="Accession(s) for this dataset. Each is FETCHED: the archive's per-sample records are "
|
|
50
|
+
"where strain/tissue/sex/dev_stage come from.",
|
|
51
|
+
),
|
|
52
|
+
records_path: Path | None = typer.Option(
|
|
53
|
+
None,
|
|
54
|
+
"--records",
|
|
55
|
+
help="An already-fetched record set (`seqforge io records`), instead of fetching now.",
|
|
56
|
+
),
|
|
57
|
+
assertions: Path | None = typer.Option(
|
|
58
|
+
None,
|
|
59
|
+
"--assertions",
|
|
60
|
+
help="Span-verified assertions from `harvest extract` (seqforge/assertions.json). Without "
|
|
61
|
+
"this, prose contributes nothing and the model might as well not have run.",
|
|
62
|
+
),
|
|
63
|
+
assert_chemistry: str | None = typer.Option(
|
|
64
|
+
None,
|
|
65
|
+
"--assert-chemistry",
|
|
66
|
+
help="Force a chemistry KB id (e.g. 10x-3p-gex-v3) as the score hypothesis, outranking any "
|
|
67
|
+
"harvested claim. Breaks a genuine byte tie (v2/v3); still only a selector, never evidence.",
|
|
68
|
+
),
|
|
69
|
+
offline: bool = typer.Option(
|
|
70
|
+
False, "--offline", help="Never reach the network. --accession then REFUSES, never quietly."
|
|
71
|
+
),
|
|
72
|
+
cpus: int = typer.Option(
|
|
73
|
+
0, "--cpus", help="Parallel probe workers. 0 = auto (min(8, CPUs)); 1 = sequential."
|
|
74
|
+
),
|
|
75
|
+
workspace: Path = typer.Option(
|
|
76
|
+
Path("."), "-C", "--workspace", help="Root for seqforge/ state."
|
|
77
|
+
),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Probe -> resolve -> assemble the DATASET manifest: what the data IS.
|
|
80
|
+
|
|
81
|
+
**Two resolvers, and they answer different questions.** `resolve score` reads the bytes and says
|
|
82
|
+
what the library is. The metadata resolver reads the archive record and any prose and says which
|
|
83
|
+
sample each file is, and what that sample was. Both can refuse; neither is shown the other's
|
|
84
|
+
input.
|
|
85
|
+
|
|
86
|
+
**Multi-run by construction.** Files are grouped into runs by name and each run's roles are
|
|
87
|
+
decided from its own bytes, so one sample per run falls out. Hand it all 12 files of a 6-run
|
|
88
|
+
dataset and you get 6 samples, not one guess.
|
|
89
|
+
|
|
90
|
+
**An accession is fetched, not decoration.** `--accession PRJNA1027859` pulls the project,
|
|
91
|
+
sample, experiment and run records and joins them to your files. That is where `tissue`, `strain`,
|
|
92
|
+
`sex` and `dev_stage` live; before this they were fetched by no code at all, which is why every
|
|
93
|
+
sample in the pilot's manifest said `tissue: null` under a paper that says "neurons".
|
|
94
|
+
|
|
95
|
+
**No accession is fine.** Most sequencing data never had one. You get samples grouped by run with
|
|
96
|
+
no facts attached, exit 0, and a manifest that is quieter and just as true.
|
|
97
|
+
|
|
98
|
+
Takes no genome. Choosing a reference is intent, not something you learn by probing bytes, so it
|
|
99
|
+
lives in `seqforge processing new`. Writes manifest.yaml ONLY after a clean validate.
|
|
100
|
+
"""
|
|
101
|
+
from ..io.remote import RemoteError
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
records = _load_records(accession, records_path, offline=offline)
|
|
105
|
+
except RemoteError as exc:
|
|
106
|
+
# Decision: no network is a refusal, not a quieter answer. You asked for this accession's
|
|
107
|
+
# facts; a manifest that silently omits them is content-addressed and permanent.
|
|
108
|
+
typer.echo(
|
|
109
|
+
json.dumps({"error": "records_unavailable", "detail": str(exc)}, indent=2), err=True
|
|
110
|
+
)
|
|
111
|
+
raise typer.Exit(3) from exc
|
|
112
|
+
|
|
113
|
+
_emit(
|
|
114
|
+
_fill_manifest_pipeline(
|
|
115
|
+
files=files,
|
|
116
|
+
organism=organism,
|
|
117
|
+
records=records,
|
|
118
|
+
assertions=assertions,
|
|
119
|
+
offline=offline,
|
|
120
|
+
workspace=workspace,
|
|
121
|
+
cpus=_auto_cpus(cpus),
|
|
122
|
+
chemistry_override=assert_chemistry,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _assay_dirname(chemistry: str) -> str:
|
|
128
|
+
"""The subdir name for an assay: its chemistry id, made filesystem-safe. Deterministic, no hash."""
|
|
129
|
+
return chemistry.replace("/", "-")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _fill_one_assay(
|
|
133
|
+
*,
|
|
134
|
+
state: Path,
|
|
135
|
+
result: Any,
|
|
136
|
+
spec: Any,
|
|
137
|
+
observations: list[Any],
|
|
138
|
+
experiment: Any,
|
|
139
|
+
role_of_sha: dict[str, str],
|
|
140
|
+
conflicts: list[Any],
|
|
141
|
+
warnings: list[Any],
|
|
142
|
+
note_workspace: Path | None,
|
|
143
|
+
uris: dict[str, str] | None = None,
|
|
144
|
+
) -> _StageOut:
|
|
145
|
+
"""Assemble + validate ONE assay's :class:`DatasetManifest` and write it under ``state``.
|
|
146
|
+
|
|
147
|
+
``state`` is the top-level ``seqforge/`` for a single-assay project (byte-identical to before) or
|
|
148
|
+
``seqforge/<assay>/`` for one of several. Each manifest is a normal single-chemistry manifest, so
|
|
149
|
+
it flows through today's exact fill/validate/hash code.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
manifest = fill_manifest(
|
|
153
|
+
result=result,
|
|
154
|
+
spec=spec,
|
|
155
|
+
observations=observations,
|
|
156
|
+
registry=DEFAULT_REGISTRY,
|
|
157
|
+
experiment=experiment,
|
|
158
|
+
seqforge_version=__version__,
|
|
159
|
+
role_of_sha=role_of_sha,
|
|
160
|
+
uris=uris,
|
|
161
|
+
)
|
|
162
|
+
except FillError as exc:
|
|
163
|
+
return _StageOut(str(exc), 3, err=True)
|
|
164
|
+
|
|
165
|
+
report = validate_manifest(manifest, conflicts=conflicts, warnings=warnings)
|
|
166
|
+
state.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
payload = yaml.safe_dump(manifest.model_dump(mode="json"), sort_keys=True)
|
|
168
|
+
# manifest.yaml exists only if it validated clean; otherwise it stays a draft (see _write_manifest).
|
|
169
|
+
target = _write_manifest(state, payload, ok=report.ok)
|
|
170
|
+
out: dict[str, object] = {"manifest": str(target), "report": report.model_dump(mode="json")}
|
|
171
|
+
if note_workspace is not None and (old := legacy_state_dir(note_workspace)) is not None:
|
|
172
|
+
out["note"] = (
|
|
173
|
+
f"{old} is from an older seqforge, which hid its state behind a dot. State now lives in "
|
|
174
|
+
f"{state}/ because it is the output, not plumbing. Nothing reads the old directory; "
|
|
175
|
+
f"delete it when you have what you need."
|
|
176
|
+
)
|
|
177
|
+
return _StageOut(out, exit_code_for_report(report))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _fill_manifest_pipeline(
|
|
181
|
+
*,
|
|
182
|
+
files: list[Path],
|
|
183
|
+
organism: str | None,
|
|
184
|
+
records: ArchiveRecordSet | None,
|
|
185
|
+
assertions: Path | None,
|
|
186
|
+
offline: bool,
|
|
187
|
+
workspace: Path,
|
|
188
|
+
cpus: int = 1,
|
|
189
|
+
chemistry_override: str | None = None,
|
|
190
|
+
) -> _StageOut:
|
|
191
|
+
"""Probe -> resolve -> metadata -> PARTITION into assays -> assemble + validate each manifest.
|
|
192
|
+
|
|
193
|
+
This is the body of ``manifest fill`` with the network I/O lifted to the caller: ``manifest fill``
|
|
194
|
+
and ``seqforge run`` fetch the archive records differently (one refuses on a miss, one caches to
|
|
195
|
+
disk first), so they hand the already-fetched set in. Every exit is a ``_StageOut`` rather than a
|
|
196
|
+
``typer.Exit`` — the standalone verb prints it and stops, ``run`` folds it into one summary and
|
|
197
|
+
decides whether to continue. Two resolvers, neither shown the other's input; both can refuse.
|
|
198
|
+
|
|
199
|
+
A project splits into **assays** — groups of samples that share one chemistry. One assay yields the
|
|
200
|
+
flat top-level layout, byte-identical to before; several yield one ``seqforge/<assay>/manifest.yaml``
|
|
201
|
+
each and an ``{"assays": [...]}`` summary. The "runs must agree" invariant is now per-SAMPLE (a
|
|
202
|
+
sample split across chemistries blocks); across samples, differing chemistries partition.
|
|
203
|
+
"""
|
|
204
|
+
from ..resolve import role_of_sha_for
|
|
205
|
+
from ..resolve.records import resolve_metadata
|
|
206
|
+
|
|
207
|
+
if chemistry_override is not None:
|
|
208
|
+
# Canonicalize to a real KB id, CASE-INSENSITIVELY — `resolve score` matches ids
|
|
209
|
+
# case-insensitively, so `manifest fill` must accept the same spellings or it would reject a
|
|
210
|
+
# value scoring would have taken. A value that resolves to nothing would otherwise silently
|
|
211
|
+
# no-op (the hypothesis just wouldn't match a candidate) and the operator would only find out
|
|
212
|
+
# after a full compile still escalated; fail fast. (list computed once — Copilot review.)
|
|
213
|
+
spec_ids = list_spec_ids()
|
|
214
|
+
canonical = {s.lower(): s for s in spec_ids}.get(chemistry_override.strip().lower())
|
|
215
|
+
if canonical is None:
|
|
216
|
+
return _StageOut(
|
|
217
|
+
{
|
|
218
|
+
"error": "unknown_chemistry",
|
|
219
|
+
"detail": f"--assert-chemistry {chemistry_override!r} is not a known KB chemistry "
|
|
220
|
+
f"id; one of: {', '.join(sorted(spec_ids))}",
|
|
221
|
+
},
|
|
222
|
+
2,
|
|
223
|
+
err=True,
|
|
224
|
+
)
|
|
225
|
+
chemistry_override = canonical
|
|
226
|
+
|
|
227
|
+
organism_taxid: int | None = None
|
|
228
|
+
if organism is not None:
|
|
229
|
+
# A name, or a taxid typed by hand. `harvest` extracts `experiment.organism` as a NAME with a
|
|
230
|
+
# verified span -- the model already does its job -- so the join it needed was a lookup table.
|
|
231
|
+
try:
|
|
232
|
+
organism_taxid = _resolve_organism(organism, offline=offline)
|
|
233
|
+
except TaxonomyUnavailable as exc:
|
|
234
|
+
return _StageOut(str(exc), 2, err=True)
|
|
235
|
+
|
|
236
|
+
parsed, subjects = _assertions_and_subjects(assertions)
|
|
237
|
+
# An operator's --assert-chemistry outranks the harvested prose: it is a deliberate, span-checked
|
|
238
|
+
# override for the one thing prose alone cannot settle — a genuine byte tie (10x v2 vs v3). It is
|
|
239
|
+
# still only a SELECTOR / tie-break into `score`, never a ninth evidence test, so it can order the
|
|
240
|
+
# candidates and break a tie the bytes cannot, but it can never overrule what the bytes decide.
|
|
241
|
+
hypothesis = (
|
|
242
|
+
Hypothesis(value=chemistry_override, id="operator", confidence=1.0)
|
|
243
|
+
if chemistry_override is not None
|
|
244
|
+
else _chemistry_hypothesis(parsed)
|
|
245
|
+
)
|
|
246
|
+
multi = resolve_runs(
|
|
247
|
+
[str(f) for f in files],
|
|
248
|
+
# The protocol paragraph (or the operator override), entering `score` as a SELECTOR and a
|
|
249
|
+
# tie-break -- never as evidence.
|
|
250
|
+
hypothesis=hypothesis,
|
|
251
|
+
workspace=workspace,
|
|
252
|
+
use_cache=False,
|
|
253
|
+
cpus=cpus,
|
|
254
|
+
)
|
|
255
|
+
# Surface any OPEN conflict / question as a human-editable questions.md (and clear a stale one on a
|
|
256
|
+
# clean re-run) BEFORE the exit-code branch below short-circuits -- `state_dir(workspace)` is exactly
|
|
257
|
+
# what the Stop hook rglobs, so a genuine cross-family disagreement is made visible and enforced.
|
|
258
|
+
_sync_questions(state_dir(workspace), multi.runs)
|
|
259
|
+
if (
|
|
260
|
+
multi.exit_code() != 0
|
|
261
|
+
): # a run that itself failed to resolve (no dataset-wide block any more)
|
|
262
|
+
return _StageOut(
|
|
263
|
+
{
|
|
264
|
+
"runs": {r.run_id: r.output.result.model_dump(mode="json") for r in multi.runs},
|
|
265
|
+
"blockers": [b.model_dump(mode="json") for b in multi.blockers],
|
|
266
|
+
},
|
|
267
|
+
multi.exit_code(),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
metadata = resolve_metadata(
|
|
271
|
+
# Identity only: the metadata resolver is handed no probe signal and cannot read one.
|
|
272
|
+
files=[o.file for o in multi.observations],
|
|
273
|
+
records=records,
|
|
274
|
+
assertions=parsed,
|
|
275
|
+
subjects=subjects,
|
|
276
|
+
)
|
|
277
|
+
if metadata.blockers:
|
|
278
|
+
return _StageOut({"blockers": [b.model_dump(mode="json") for b in metadata.blockers]}, 3)
|
|
279
|
+
|
|
280
|
+
# The relocated invariant: a single sample whose files span more than one chemistry is a
|
|
281
|
+
# mis-grouping and blocks. Different chemistries across DIFFERENT samples are a legal partition.
|
|
282
|
+
sample_shas = {s.sample_id: list(s.file_shas) for s in metadata.samples}
|
|
283
|
+
if sample_blockers := multi.sample_disagreements(sample_shas):
|
|
284
|
+
return _StageOut({"blockers": [b.model_dump(mode="json") for b in sample_blockers]}, 3)
|
|
285
|
+
|
|
286
|
+
groups = multi.by_chemistry()
|
|
287
|
+
if not groups: # every run carried its own blocker (caught above); nothing to build
|
|
288
|
+
return _StageOut({"error": "no run resolved to a chemistry"}, 3)
|
|
289
|
+
chem_of = multi.chemistry_of_sha()
|
|
290
|
+
multi_assay = len(groups) > 1
|
|
291
|
+
# ONE dataset-wide URI map, computed over EVERY file's common root, shared by every assay. A
|
|
292
|
+
# per-assay fill otherwise re-derives the root from only its own (deeper) subset, so a sample in
|
|
293
|
+
# an `SRX.../` subdir gets a URI missing that segment while `--fastq-dir` is the dataset root --
|
|
294
|
+
# the units path then does not exist and the wiring gate fails. Single-assay is unaffected: its
|
|
295
|
+
# obs already IS `multi.observations`, so this is the identical map and the manifest hash is
|
|
296
|
+
# byte-for-byte unchanged.
|
|
297
|
+
file_uris = dataset_uris(multi.observations)
|
|
298
|
+
|
|
299
|
+
def _build(tech: str, runs: list[Any], state: Path, note_ws: Path | None) -> _StageOut:
|
|
300
|
+
if multi_assay:
|
|
301
|
+
obs = [o for o in multi.observations if chem_of.get(o.file.sha256) == tech]
|
|
302
|
+
samples = [
|
|
303
|
+
s for s in metadata.samples if s.file_shas and chem_of.get(s.file_shas[0]) == tech
|
|
304
|
+
]
|
|
305
|
+
resolution = metadata.model_copy(update={"samples": samples})
|
|
306
|
+
else:
|
|
307
|
+
obs, resolution = multi.observations, metadata
|
|
308
|
+
# Only the BYTE resolver's conflicts block; a metadata disagreement rides in as a warning.
|
|
309
|
+
conflicts = [c for run in runs for c in run.output.result.conflicts]
|
|
310
|
+
try:
|
|
311
|
+
experiment = experiment_from_metadata(
|
|
312
|
+
resolution, obs, organism_taxid=organism_taxid, uris=file_uris
|
|
313
|
+
)
|
|
314
|
+
except FillError as exc:
|
|
315
|
+
return _StageOut(str(exc), 3, err=True)
|
|
316
|
+
return _fill_one_assay(
|
|
317
|
+
state=state,
|
|
318
|
+
result=runs[0].output.result, # every run of the assay agreed; any one is the assay's
|
|
319
|
+
spec=load_spec(tech),
|
|
320
|
+
observations=obs,
|
|
321
|
+
experiment=experiment,
|
|
322
|
+
role_of_sha=role_of_sha_for(runs),
|
|
323
|
+
conflicts=conflicts,
|
|
324
|
+
warnings=metadata.warnings,
|
|
325
|
+
note_workspace=note_ws,
|
|
326
|
+
uris=file_uris,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
if not multi_assay:
|
|
330
|
+
tech, runs = next(iter(groups.items()))
|
|
331
|
+
return _build(tech, runs, state_dir(workspace), workspace)
|
|
332
|
+
|
|
333
|
+
assays: list[dict[str, object]] = []
|
|
334
|
+
worst = 0
|
|
335
|
+
for tech, runs in groups.items():
|
|
336
|
+
n_samples = sum(
|
|
337
|
+
1 for s in metadata.samples if s.file_shas and chem_of.get(s.file_shas[0]) == tech
|
|
338
|
+
)
|
|
339
|
+
out = _build(tech, runs, state_dir(workspace, _assay_dirname(tech)), None)
|
|
340
|
+
worst = max(worst, out.code)
|
|
341
|
+
entry: dict[str, object] = {
|
|
342
|
+
"chemistry": tech,
|
|
343
|
+
"assay_dir": _assay_dirname(tech),
|
|
344
|
+
"n_samples": n_samples,
|
|
345
|
+
}
|
|
346
|
+
entry.update(out.payload if isinstance(out.payload, dict) else {"error": out.payload})
|
|
347
|
+
assays.append(entry)
|
|
348
|
+
return _StageOut({"assays": assays, "n_assays": len(assays)}, worst)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _write_manifest(state: Path, payload: str, *, ok: bool) -> Path:
|
|
352
|
+
"""Write manifest.yaml OR manifest.draft.yaml, and remove the other.
|
|
353
|
+
|
|
354
|
+
The removal is the fix. `fill` wrote one name or the other and never unlinked its sibling, so a
|
|
355
|
+
run that failed and was then fixed left `manifest.draft.yaml` sitting next to a good
|
|
356
|
+
`manifest.yaml` forever -- and, far worse, a manifest that USED to validate and now does not left
|
|
357
|
+
the stale clean `manifest.yaml` in place while reporting a draft. Every downstream verb reads
|
|
358
|
+
`manifest.yaml` by name. It would have compiled the old one and said nothing.
|
|
359
|
+
|
|
360
|
+
Exactly one of the two exists when this returns. That is the whole contract, and it is what
|
|
361
|
+
"manifest.yaml exists only if it validated clean" was always supposed to mean.
|
|
362
|
+
"""
|
|
363
|
+
target = state / ("manifest.yaml" if ok else "manifest.draft.yaml")
|
|
364
|
+
other = state / ("manifest.draft.yaml" if ok else "manifest.yaml")
|
|
365
|
+
target.write_text(payload)
|
|
366
|
+
other.unlink(missing_ok=True)
|
|
367
|
+
return target
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _render_questions(conflicts: list[tuple[str, Any]], questions: list[tuple[str, Any]]) -> str:
|
|
371
|
+
"""A human-editable Markdown surfacing of the open conflicts / questions blocking this dataset."""
|
|
372
|
+
lines = [
|
|
373
|
+
"# Open questions for this dataset",
|
|
374
|
+
"",
|
|
375
|
+
"seqforge stopped because the metadata and the FASTQ bytes disagree in a way code will not",
|
|
376
|
+
"settle on its own — this can mean a methods-writing slip or a wrong data-vs-paper pairing, and",
|
|
377
|
+
"either way a human should look. Resolve each item below, then re-run: this file clears itself",
|
|
378
|
+
"once nothing is open.",
|
|
379
|
+
"",
|
|
380
|
+
]
|
|
381
|
+
for run_id, c in conflicts:
|
|
382
|
+
by = {p.basis: p.value for p in c.positions}
|
|
383
|
+
lines += [
|
|
384
|
+
f"## Conflict `{c.id}` — {c.field} (run {run_id})",
|
|
385
|
+
f"- **asserted** (from the paper / metadata): `{by.get('asserted', '?')}`",
|
|
386
|
+
f"- **observed** (from the reads): `{by.get('observed', '?')}`",
|
|
387
|
+
f"- decidable by: {', '.join(c.decidable_by)}",
|
|
388
|
+
"",
|
|
389
|
+
"If the paper is right and you have confirmed it, re-run with "
|
|
390
|
+
"`--assert-chemistry=<observed id>` after judging. If the FASTQs do not match this "
|
|
391
|
+
"accession, the data-vs-paper pairing may be wrong — investigate before compiling.",
|
|
392
|
+
"",
|
|
393
|
+
]
|
|
394
|
+
for run_id, q in questions:
|
|
395
|
+
opts = ", ".join(f"`{o}`" for o in q.options)
|
|
396
|
+
lines += [
|
|
397
|
+
f"## Question `{q.id}` — {q.field} (run {run_id})",
|
|
398
|
+
f"{q.prompt}",
|
|
399
|
+
f"- options: {opts}",
|
|
400
|
+
f"- decidable by: {', '.join(q.decidable_by)}",
|
|
401
|
+
"",
|
|
402
|
+
]
|
|
403
|
+
return "\n".join(lines)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _sync_questions(state: Path, runs: list[Any]) -> None:
|
|
407
|
+
"""Write ``state/questions.md`` for open conflicts / questions across runs; clear it when none.
|
|
408
|
+
|
|
409
|
+
The Stop hook refuses to end a turn while any non-empty ``questions.md`` exists under ``seqforge/``,
|
|
410
|
+
so a genuine (cross-family) conflict becomes a visible, enforced artifact. Writing AND clearing here
|
|
411
|
+
— before the pipeline's exit-code branch — keeps the two symmetric: a re-run that resolves the
|
|
412
|
+
disagreement removes the file, so the hook cannot wedge on a stale question. A within-family
|
|
413
|
+
difference is recorded as a ``resolved`` conflict, so it is not ``open`` and never writes here.
|
|
414
|
+
"""
|
|
415
|
+
open_conflicts = [
|
|
416
|
+
(r.run_id, c) for r in runs for c in r.output.result.conflicts if c.status == "open"
|
|
417
|
+
]
|
|
418
|
+
open_questions = [(r.run_id, q) for r in runs for q in r.output.result.questions]
|
|
419
|
+
path = state / "questions.md"
|
|
420
|
+
if not open_conflicts and not open_questions:
|
|
421
|
+
path.unlink(missing_ok=True)
|
|
422
|
+
return
|
|
423
|
+
state.mkdir(parents=True, exist_ok=True)
|
|
424
|
+
path.write_text(_render_questions(open_conflicts, open_questions))
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _chemistry_hypothesis(assertions: list[Assertion]) -> Hypothesis | None:
|
|
428
|
+
"""The chemistry the prose claims, entering `score` as a hypothesis. ``None`` when it cannot.
|
|
429
|
+
|
|
430
|
+
**What this is allowed to do.** `score` builds a grid — one row per read role, one column per
|
|
431
|
+
file — from eight byte-tests, and the hypothesis touches none of them. It orders the candidates
|
|
432
|
+
(so the right whitelist is checked first) and it can break a tie the bytes genuinely cannot
|
|
433
|
+
settle. For prose to move a *score* there would have to be a ninth test, `metadata_says`, and a
|
|
434
|
+
spec could then declare a chemistry that identifies itself by being described rather than by
|
|
435
|
+
what is in its reads. That is the thing we do not build.
|
|
436
|
+
|
|
437
|
+
**Agreement or nothing.** Every chemistry claim in the dataset must say the same thing. Two
|
|
438
|
+
experiments describing two protocols is a real dataset, and one dataset-level hypothesis would
|
|
439
|
+
steer both — half of them wrongly. Dropping it costs only a hint: the bytes still decide, and if
|
|
440
|
+
the runs really are two chemistries, `resolve_runs` blocks on the disagreement, which is the right
|
|
441
|
+
answer arrived at honestly.
|
|
442
|
+
"""
|
|
443
|
+
values = {a.value for a in assertions if a.field == "library.chemistry"}
|
|
444
|
+
if len(values) != 1:
|
|
445
|
+
return None
|
|
446
|
+
return Hypothesis(value=next(iter(values)), id="harvest", confidence=0.9)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _load_records(
|
|
450
|
+
accessions: list[str], records_path: Path | None, *, offline: bool
|
|
451
|
+
) -> ArchiveRecordSet | None:
|
|
452
|
+
"""The archive records for this dataset, or ``None`` if nobody named one.
|
|
453
|
+
|
|
454
|
+
``None`` is the common case and is not a degradation: a plate sequenced last week has no
|
|
455
|
+
accession. But an accession that was *given* and cannot be fetched is a refusal — you asked for
|
|
456
|
+
those facts, and a manifest is content-addressed and never rewritten, so quietly omitting them
|
|
457
|
+
would bake the omission in.
|
|
458
|
+
"""
|
|
459
|
+
from ..io.archive import fetch_records
|
|
460
|
+
from ..io.remote import RemoteError
|
|
461
|
+
from ..models.records import ArchiveRecordSet
|
|
462
|
+
|
|
463
|
+
if records_path is not None:
|
|
464
|
+
return ArchiveRecordSet.model_validate_json(records_path.read_text())
|
|
465
|
+
if not accessions:
|
|
466
|
+
return None
|
|
467
|
+
if offline:
|
|
468
|
+
raise RemoteError(
|
|
469
|
+
f"--accession {', '.join(accessions)} needs the archive, and --offline forbids it. "
|
|
470
|
+
f"Fetch the records once with `seqforge io records {accessions[0]}` and pass "
|
|
471
|
+
f"`--records`, or drop --accession to compile with no sample facts."
|
|
472
|
+
)
|
|
473
|
+
merged: list[Any] = []
|
|
474
|
+
for acc in accessions:
|
|
475
|
+
merged.extend(fetch_records(acc).records)
|
|
476
|
+
return ArchiveRecordSet(
|
|
477
|
+
source="ncbi-sra+biosample", query=", ".join(accessions), records=merged
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _assertions_and_subjects(path: Path | None) -> tuple[list[Assertion], list[Any]]:
|
|
482
|
+
"""Read `harvest extract`'s artifact: the claims, and which record each document came from.
|
|
483
|
+
|
|
484
|
+
``document_subjects`` is the same trick as ``instruction_docs`` beside it — a code-owned mapping
|
|
485
|
+
from document to what code knows about it, written down so a later process can reconstruct it.
|
|
486
|
+
Without it, an assertion's ``doc_sha256`` is an opaque hash and the resolver cannot tell a
|
|
487
|
+
sample's own alias from a paper about six samples, which is the entire difference between a
|
|
488
|
+
declaration and an inference.
|
|
489
|
+
"""
|
|
490
|
+
from ..resolve.records import DocumentSubject
|
|
491
|
+
|
|
492
|
+
if path is None:
|
|
493
|
+
return [], []
|
|
494
|
+
payload = json.loads(path.read_text())
|
|
495
|
+
if isinstance(payload, list):
|
|
496
|
+
raise ValueError(
|
|
497
|
+
"this looks like a pre-2026.7 assertions.json (a bare list). It cannot say which "
|
|
498
|
+
"document each claim came from, so re-run `seqforge harvest extract`."
|
|
499
|
+
)
|
|
500
|
+
parsed = [Assertion.model_validate(a) for a in payload.get("assertions", ())]
|
|
501
|
+
subjects = [
|
|
502
|
+
DocumentSubject(
|
|
503
|
+
doc_sha256=str(d["doc_sha256"]), scope=str(d["scope"]), subject=d.get("subject")
|
|
504
|
+
)
|
|
505
|
+
for d in payload.get("document_subjects", ())
|
|
506
|
+
]
|
|
507
|
+
return parsed, subjects
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@manifest_app.command("validate")
|
|
511
|
+
def manifest_validate(
|
|
512
|
+
manifest_path: Path = typer.Argument(..., help="Path to a manifest.yaml."),
|
|
513
|
+
) -> None:
|
|
514
|
+
"""Validate a manifest. Exit 3 on a Blocker, 4 on an open Conflict."""
|
|
515
|
+
report = validate_manifest(_load_manifest(manifest_path))
|
|
516
|
+
typer.echo(json.dumps(report.model_dump(mode="json"), indent=2))
|
|
517
|
+
raise typer.Exit(exit_code_for_report(report))
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@manifest_app.command("hash")
|
|
521
|
+
def manifest_hash_cmd(
|
|
522
|
+
manifest_path: Path = typer.Argument(..., help="Path to a manifest.yaml."),
|
|
523
|
+
) -> None:
|
|
524
|
+
"""Print the dataset manifest's content hash and whether it matches the recorded one."""
|
|
525
|
+
manifest = _load_manifest(manifest_path)
|
|
526
|
+
content = dataset_content_hash(manifest)
|
|
527
|
+
typer.echo(
|
|
528
|
+
json.dumps(
|
|
529
|
+
{
|
|
530
|
+
"dataset_hash": content,
|
|
531
|
+
"recorded_hash": manifest.provenance.dataset_hash,
|
|
532
|
+
"matches": content == manifest.provenance.dataset_hash,
|
|
533
|
+
},
|
|
534
|
+
indent=2,
|
|
535
|
+
)
|
|
536
|
+
)
|
seqforge/cli/probe.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""`seqforge probe` -- deterministic, bounded FASTQ fingerprinting into role-free Observations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .root import app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("probe")
|
|
14
|
+
def probe_cmd(
|
|
15
|
+
files: list[Path] = typer.Argument(..., help="FASTQ .gz files to fingerprint."),
|
|
16
|
+
workspace: Path = typer.Option(
|
|
17
|
+
Path("."), "-C", "--workspace", help="Root for seqforge/ state."
|
|
18
|
+
),
|
|
19
|
+
max_reads: int = typer.Option(200_000, help="Bounded read budget."),
|
|
20
|
+
max_bytes: int = typer.Option(256 * 1024 * 1024, help="Bounded decompressed-byte cap."),
|
|
21
|
+
no_cache: bool = typer.Option(False, "--no-cache", help="Do not write seqforge/ artifacts."),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Fingerprint FASTQ bytes into role-free Observations. No LLM, no network, bounded.
|
|
24
|
+
|
|
25
|
+
The budget is the point: a 40 GB file costs the same as a 40 MB one, because probe stops at
|
|
26
|
+
--max-reads AND --max-bytes, whichever comes first. Never returns 3/4 — it only observes; refusal
|
|
27
|
+
happens downstream when a validator reads the observation.
|
|
28
|
+
"""
|
|
29
|
+
from ..probe import probe_file
|
|
30
|
+
from ..resolve import Cache
|
|
31
|
+
|
|
32
|
+
cache = Cache(workspace) if not no_cache else None
|
|
33
|
+
observations = []
|
|
34
|
+
for path in files:
|
|
35
|
+
try:
|
|
36
|
+
obs = probe_file(path, max_reads=max_reads, max_bytes=max_bytes)
|
|
37
|
+
except (OSError, ValueError) as exc:
|
|
38
|
+
typer.echo(json.dumps({"error": f"{path}: {exc}"}, indent=2), err=True)
|
|
39
|
+
raise typer.Exit(1) from exc
|
|
40
|
+
if cache is not None:
|
|
41
|
+
cache.write_observation(obs)
|
|
42
|
+
observations.append(obs.model_dump(mode="json"))
|
|
43
|
+
typer.echo(json.dumps(observations if len(observations) > 1 else observations[0], indent=2))
|