seqforge 2026.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. seqforge/__init__.py +16 -0
  2. seqforge/cli/__init__.py +38 -0
  3. seqforge/cli/__main__.py +8 -0
  4. seqforge/cli/_common.py +105 -0
  5. seqforge/cli/compose.py +119 -0
  6. seqforge/cli/eval.py +103 -0
  7. seqforge/cli/harvest.py +417 -0
  8. seqforge/cli/hook.py +247 -0
  9. seqforge/cli/io.py +502 -0
  10. seqforge/cli/kb.py +348 -0
  11. seqforge/cli/manifest.py +536 -0
  12. seqforge/cli/probe.py +43 -0
  13. seqforge/cli/processing.py +192 -0
  14. seqforge/cli/project.py +52 -0
  15. seqforge/cli/resolve.py +55 -0
  16. seqforge/cli/root.py +66 -0
  17. seqforge/cli/run.py +463 -0
  18. seqforge/cli/schema.py +41 -0
  19. seqforge/compose/__init__.py +28 -0
  20. seqforge/compose/core.py +515 -0
  21. seqforge/compose/gates.py +113 -0
  22. seqforge/compose/params.py +447 -0
  23. seqforge/e2e.py +1926 -0
  24. seqforge/evals/__init__.py +78 -0
  25. seqforge/evals/case.py +382 -0
  26. seqforge/evals/grade.py +300 -0
  27. seqforge/evals/run.py +420 -0
  28. seqforge/harvest/__init__.py +121 -0
  29. seqforge/harvest/extract.py +319 -0
  30. seqforge/harvest/fields.py +212 -0
  31. seqforge/harvest/normalize.py +537 -0
  32. seqforge/harvest/prep.py +41 -0
  33. seqforge/harvest/providers.py +321 -0
  34. seqforge/harvest/verify.py +251 -0
  35. seqforge/hooks/__init__.py +33 -0
  36. seqforge/hooks/guards.py +214 -0
  37. seqforge/io/__init__.py +61 -0
  38. seqforge/io/archive.py +450 -0
  39. seqforge/io/attributes.py +190 -0
  40. seqforge/io/biosample/attributes.json +6341 -0
  41. seqforge/io/efo/labels.json +55 -0
  42. seqforge/io/efo.py +138 -0
  43. seqforge/io/onlist.py +661 -0
  44. seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
  45. seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
  46. seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
  47. seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
  48. seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
  49. seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
  50. seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
  51. seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
  52. seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
  53. seqforge/io/onlists/index.json +74 -0
  54. seqforge/io/remote.py +659 -0
  55. seqforge/io/taxonomy.py +194 -0
  56. seqforge/kb/__init__.py +62 -0
  57. seqforge/kb/anchor.py +169 -0
  58. seqforge/kb/generate.py +147 -0
  59. seqforge/kb/loader.py +152 -0
  60. seqforge/kb/roundtrip.py +112 -0
  61. seqforge/kb/schema.py +422 -0
  62. seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
  63. seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
  64. seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
  65. seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
  66. seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
  67. seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
  68. seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
  69. seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
  70. seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
  71. seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
  72. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
  73. seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
  74. seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
  75. seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
  76. seqforge/kb/specs/splitseq/README.md +51 -0
  77. seqforge/kb/specs/splitseq/spec.yaml +157 -0
  78. seqforge/manifest/__init__.py +61 -0
  79. seqforge/manifest/fill.py +531 -0
  80. seqforge/manifest/hash.py +77 -0
  81. seqforge/manifest/instruct.py +114 -0
  82. seqforge/manifest/policy.py +409 -0
  83. seqforge/manifest/validate.py +274 -0
  84. seqforge/models/__init__.py +268 -0
  85. seqforge/models/assertion.py +68 -0
  86. seqforge/models/base.py +100 -0
  87. seqforge/models/blocker.py +71 -0
  88. seqforge/models/conflict.py +47 -0
  89. seqforge/models/dataset.py +320 -0
  90. seqforge/models/evidenced.py +54 -0
  91. seqforge/models/observation.py +157 -0
  92. seqforge/models/processing.py +231 -0
  93. seqforge/models/records.py +145 -0
  94. seqforge/models/resolve.py +216 -0
  95. seqforge/probe/__init__.py +46 -0
  96. seqforge/probe/core.py +232 -0
  97. seqforge/probe/signals.py +250 -0
  98. seqforge/probe/streaming.py +118 -0
  99. seqforge/project.py +177 -0
  100. seqforge/py.typed +0 -0
  101. seqforge/resolve/__init__.py +98 -0
  102. seqforge/resolve/assign.py +204 -0
  103. seqforge/resolve/cache.py +119 -0
  104. seqforge/resolve/confuse.py +215 -0
  105. seqforge/resolve/engine.py +646 -0
  106. seqforge/resolve/escalate.py +668 -0
  107. seqforge/resolve/evaluators.py +306 -0
  108. seqforge/resolve/geometry.py +89 -0
  109. seqforge/resolve/group.py +85 -0
  110. seqforge/resolve/records.py +550 -0
  111. seqforge/resolve/scoring.py +373 -0
  112. seqforge/resolve/window.py +206 -0
  113. seqforge/workflows/__init__.py +234 -0
  114. seqforge/workflows/cram.py +117 -0
  115. seqforge/workflows/h5ad.py +368 -0
  116. seqforge/workflows/map/star.smk +101 -0
  117. seqforge/workflows/map/starsolo.smk +360 -0
  118. seqforge/workflows/qc.py +157 -0
  119. seqforge/workspace.py +125 -0
  120. seqforge-2026.7.1.dist-info/METADATA +125 -0
  121. seqforge-2026.7.1.dist-info/RECORD +124 -0
  122. seqforge-2026.7.1.dist-info/WHEEL +4 -0
  123. seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
  124. seqforge-2026.7.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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))