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,192 @@
1
+ """`seqforge processing` -- the PROCESSING manifest: what to DO with a dataset. Many per dataset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ import yaml
10
+ from pydantic import ValidationError
11
+
12
+ from .. import __version__
13
+ from ..kb import load_spec
14
+ from ..manifest import (
15
+ Instruction,
16
+ PolicyError,
17
+ ProcessingInputs,
18
+ exit_code_for_report,
19
+ fill_processing,
20
+ instructions_from_assertions,
21
+ prep_type_from_assertions,
22
+ processing_content_hash,
23
+ validate_processing,
24
+ )
25
+ from ..models.assertion import Assertion
26
+ from ._common import _load_manifest, _load_processing, _parse_quantify
27
+ from .root import processing_app
28
+
29
+
30
+ def _instructions_from(path: Path | None) -> list[Instruction]:
31
+ """Rebuild the instructable surface from `harvest extract`'s artifact.
32
+
33
+ The precedence ladder (§7) is flag > instruction > policy, and `resolve_processing` has always
34
+ implemented it — its `PolicyError` even tells you to "name an assembly in an --instruction
35
+ document". That branch was unreachable: `--assembly` was a REQUIRED option, and nothing passed
36
+ `instructions=` from any production caller. This is the last mile of a join that already existed.
37
+
38
+ Note what is NOT happening: the model does not decide anything here. It found a claim in prose and
39
+ code verified the quote greps back and entails the value; this reads that record and applies
40
+ precedence. "We can accept instructions because we never trust the model to act on them, only to
41
+ find them."
42
+ """
43
+ if path is None:
44
+ return []
45
+ payload = json.loads(path.read_text())
46
+ if isinstance(payload, list):
47
+ raise ValueError(
48
+ "this looks like a pre-2026.7 assertions.json (a bare list). It cannot say which "
49
+ "documents were --instruction, and only those may set processing.*. Re-run "
50
+ "`seqforge harvest extract`."
51
+ )
52
+ docs = frozenset(payload.get("instruction_docs", ()))
53
+ parsed = [Assertion.model_validate(a) for a in payload.get("assertions", ())]
54
+ instructions, conflicts = instructions_from_assertions(parsed, instruction_docs=docs)
55
+ if conflicts:
56
+ raise ValueError(
57
+ f"{len(conflicts)} instruction(s) disagree with each other; only their author can "
58
+ f"settle that: " + "; ".join(c.field for c in conflicts)
59
+ )
60
+ return instructions
61
+
62
+
63
+ def _prep_type_from(path: Path | None) -> str | None:
64
+ """The cells-vs-nuclei prep from `harvest extract`'s artifact, normalized. `None` if absent.
65
+
66
+ A span-verified biology fact (``library.prep_type``) that code — not the model — maps to the
67
+ primary soloFeature: a single-nucleus prep promotes GeneFull. The model named the biology and
68
+ verified its quote; this reads that record. See :func:`resolve_features`.
69
+ """
70
+ if path is None:
71
+ return None
72
+ payload = json.loads(path.read_text())
73
+ if isinstance(payload, list): # a pre-2026.7 bare list; `_instructions_from` already refuses it
74
+ return None
75
+ parsed = [Assertion.model_validate(a) for a in payload.get("assertions", ())]
76
+ return prep_type_from_assertions(parsed)
77
+
78
+
79
+ @processing_app.command("new")
80
+ def processing_new(
81
+ dataset_path: Path = typer.Argument(..., help="Path to the dataset manifest.yaml."),
82
+ assembly: str | None = typer.Option(
83
+ None, "--assembly", help="liulab-genome UCSC assembly id (e.g. ce11)."
84
+ ),
85
+ annotation: str | None = typer.Option(
86
+ None, "--annotation", help="Registered GTF name (e.g. WS298)."
87
+ ),
88
+ assertions: Path | None = typer.Option(
89
+ None,
90
+ "--assertions",
91
+ help="Span-verified assertions from `harvest extract` (seqforge/assertions.json). "
92
+ "Instructions in them fill what no flag supplied.",
93
+ ),
94
+ quantify: str | None = typer.Option(
95
+ None,
96
+ "--quantify",
97
+ help="Comma-separated soloFeatures. EXACT replacement of the default (which counts all five).",
98
+ ),
99
+ threads: int | None = typer.Option(None, "--threads", help="Threads per mapping job."),
100
+ processing_id: str = typer.Option("default", "--id", help="Human slug for this recipe."),
101
+ pin: bool = typer.Option(
102
+ True,
103
+ "--pin/--template",
104
+ help="Bind to this dataset's hash, or leave it portable across datasets.",
105
+ ),
106
+ out: Path | None = typer.Option(None, "-o", "--out", help="Write here (default: stdout)."),
107
+ ) -> None:
108
+ """Author a PROCESSING manifest: what to DO with a dataset. Many per dataset.
109
+
110
+ With no flags you get the policy default, which counts every soloFeature — so the common
111
+ case needs no decision from you. --quantify replaces that list exactly; narrowing it warns,
112
+ because dropping a feature is the only irreversible act here.
113
+ """
114
+ dataset = _load_manifest(dataset_path)
115
+ spec = load_spec(dataset.library.chemistry.value[0])
116
+ try:
117
+ instructions = _instructions_from(assertions)
118
+ prep_type = _prep_type_from(assertions)
119
+ except (OSError, ValueError, ValidationError) as exc:
120
+ typer.echo(f"{assertions}: {exc}", err=True)
121
+ raise typer.Exit(2) from exc
122
+ try:
123
+ processing, warnings = fill_processing(
124
+ spec=spec,
125
+ dataset=dataset,
126
+ processing=ProcessingInputs(
127
+ assembly=assembly,
128
+ annotation_name=annotation,
129
+ features=_parse_quantify(quantify),
130
+ threads=threads,
131
+ ),
132
+ instructions=instructions,
133
+ prep_type=prep_type,
134
+ processing_id=processing_id,
135
+ pin=pin,
136
+ seqforge_version=__version__,
137
+ )
138
+ except (PolicyError, ValidationError) as exc:
139
+ typer.echo(str(exc), err=True)
140
+ raise typer.Exit(2) from exc
141
+ report = validate_processing(processing, dataset=dataset)
142
+ payload = yaml.safe_dump(processing.model_dump(mode="json"), sort_keys=True)
143
+ if out is not None:
144
+ out.write_text(payload)
145
+ typer.echo(
146
+ json.dumps(
147
+ {
148
+ "processing": str(out),
149
+ "report": report.model_dump(mode="json"),
150
+ "warnings": [w.model_dump(mode="json") for w in warnings],
151
+ },
152
+ indent=2,
153
+ )
154
+ )
155
+ else:
156
+ typer.echo(payload)
157
+ raise typer.Exit(exit_code_for_report(report))
158
+
159
+
160
+ @processing_app.command("validate")
161
+ def processing_validate(
162
+ processing_path: Path = typer.Argument(..., help="Path to a processing.yaml."),
163
+ dataset_path: Path | None = typer.Option(
164
+ None, "--dataset", help="Cross-check against this dataset manifest (pin + organism)."
165
+ ),
166
+ ) -> None:
167
+ """Validate a processing manifest. Exit 3 on a Blocker."""
168
+ processing = _load_processing(processing_path)
169
+ dataset = _load_manifest(dataset_path) if dataset_path is not None else None
170
+ report = validate_processing(processing, dataset=dataset)
171
+ typer.echo(json.dumps(report.model_dump(mode="json"), indent=2))
172
+ raise typer.Exit(exit_code_for_report(report))
173
+
174
+
175
+ @processing_app.command("hash")
176
+ def processing_hash_cmd(
177
+ processing_path: Path = typer.Argument(..., help="Path to a processing.yaml."),
178
+ ) -> None:
179
+ """Print the processing manifest's content hash and whether it matches the recorded one."""
180
+ processing = _load_processing(processing_path)
181
+ content = processing_content_hash(processing)
182
+ typer.echo(
183
+ json.dumps(
184
+ {
185
+ "processing_hash": content,
186
+ "recorded_hash": processing.provenance.processing_hash,
187
+ "matches": content == processing.provenance.processing_hash,
188
+ "pinned_to": processing.dataset.dataset_hash if processing.dataset else None,
189
+ },
190
+ indent=2,
191
+ )
192
+ )
@@ -0,0 +1,52 @@
1
+ """`seqforge project` -- project-level views over a multi-assay compile (sample_metadata + index)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from ._common import _load_manifest
11
+ from .root import project_app
12
+
13
+
14
+ @project_app.command("metadata")
15
+ def project_metadata(
16
+ workspace: Path = typer.Option(Path("."), "-C", "--workspace", help="Root holding seqforge/."),
17
+ ) -> None:
18
+ """Regenerate seqforge/sample_metadata.tsv + project.yaml from the per-assay manifest(s).
19
+
20
+ Reads whatever manifests are already under seqforge/ — a single top-level manifest.yaml, or one
21
+ per assay subdir — and unions their samples into the flat one-row-per-sample table + the assay
22
+ index. Deterministic: same manifests in, same files out. `run` writes these automatically; this
23
+ verb rebuilds them (after editing a manifest, say) without recompiling.
24
+ """
25
+ from ..project import discover_assays, write_project_views
26
+
27
+ assays = discover_assays(workspace)
28
+ if not assays:
29
+ typer.echo(json.dumps({"error": "no manifest.yaml found under seqforge/"}), err=True)
30
+ raise typer.Exit(3)
31
+ infos: list[dict[str, object]] = []
32
+ for subdir, manifest_path in assays:
33
+ manifest = _load_manifest(manifest_path)
34
+ infos.append(
35
+ {
36
+ "chemistry": manifest.library.chemistry.value[0],
37
+ "subdir": subdir,
38
+ "n_samples": len(manifest.experiment.samples),
39
+ "manifest": str(manifest_path),
40
+ }
41
+ )
42
+ tsv_path, project_path = write_project_views(workspace, infos)
43
+ typer.echo(
44
+ json.dumps(
45
+ {
46
+ "sample_metadata": str(tsv_path),
47
+ "project": str(project_path),
48
+ "n_assays": len(infos),
49
+ },
50
+ indent=2,
51
+ )
52
+ )
@@ -0,0 +1,55 @@
1
+ """`seqforge resolve` -- score FASTQ bytes + KB into a ranked, escalated chemistry decision."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from ..resolve import Hypothesis, resolve_dataset
11
+ from ._common import _auto_cpus
12
+ from .root import resolve_app
13
+
14
+
15
+ @resolve_app.command("score")
16
+ def resolve_score(
17
+ files: list[Path] = typer.Argument(..., help="The dataset's FASTQ .gz files."),
18
+ workspace: Path = typer.Option(
19
+ Path("."), "-C", "--workspace", help="Root for seqforge/ state."
20
+ ),
21
+ assert_chemistry: str | None = typer.Option(
22
+ None,
23
+ "--assert-chemistry",
24
+ help="A metadata-asserted chemistry (the span-verified hypothesis).",
25
+ ),
26
+ explain: bool = typer.Option(
27
+ False, "--explain", help="Also emit the JSON-safe evidence matrices."
28
+ ),
29
+ no_cache: bool = typer.Option(
30
+ False, "--no-cache", help="Do not read/write seqforge/ artifacts."
31
+ ),
32
+ max_reads: int = typer.Option(200_000, help="Bounded read budget."),
33
+ max_bytes: int = typer.Option(256 * 1024 * 1024, help="Bounded decompressed-byte cap."),
34
+ cpus: int = typer.Option(
35
+ 0, "--cpus", help="Parallel probe workers. 0 = auto (min(8, CPUs)); 1 = sequential."
36
+ ),
37
+ ) -> None:
38
+ """Score FASTQ bytes + KB into a ResolveResult. Exit 3 on a Blocker, 4 on an open Conflict/question."""
39
+ hypothesis = Hypothesis(value=assert_chemistry) if assert_chemistry else None
40
+ output = resolve_dataset(
41
+ [str(f) for f in files],
42
+ hypothesis=hypothesis,
43
+ workspace=workspace,
44
+ max_reads=max_reads,
45
+ max_bytes=max_bytes,
46
+ use_cache=not no_cache,
47
+ cpus=_auto_cpus(cpus),
48
+ )
49
+ payload: dict[str, object] = output.result.model_dump(mode="json")
50
+ if explain:
51
+ payload = {"result": payload, "matrices": output.matrices}
52
+ typer.echo(json.dumps(payload, indent=2))
53
+ code = output.exit_code()
54
+ if code != 0:
55
+ raise typer.Exit(code)
seqforge/cli/root.py ADDED
@@ -0,0 +1,66 @@
1
+ """The root Typer app and every command group's sub-Typer, wired together.
2
+
3
+ Defined in one place so a command module can import exactly the group it registers onto without
4
+ pulling in its siblings. The ``add_typer`` wiring here fixes the CLI's shape; the verbs attach when
5
+ each command module is imported (see this package's ``__init__``). Introspected by
6
+ ``test_skills.py`` -- a renamed verb goes red there, not here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import typer
12
+
13
+ from .. import __version__
14
+
15
+ app = typer.Typer(
16
+ name="seqforge",
17
+ help="Compile FASTQ + metadata into a validated library manifest and a Snakemake config.",
18
+ no_args_is_help=True,
19
+ add_completion=False,
20
+ )
21
+
22
+ schema_app = typer.Typer(help="Export JSON Schema from the Pydantic models (the source of truth).")
23
+ app.add_typer(schema_app, name="schema")
24
+
25
+ kb_app = typer.Typer(help="The executable, self-testing knowledge base.")
26
+ app.add_typer(kb_app, name="kb")
27
+
28
+ io_app = typer.Typer(help="The network + onlist surface (pooch-cached, sha256-verified).")
29
+ app.add_typer(io_app, name="io")
30
+
31
+ onlist_app = typer.Typer(help="Barcode-whitelist (onlist) registry.")
32
+ io_app.add_typer(onlist_app, name="onlist")
33
+
34
+ resolve_app = typer.Typer(help="Score bytes + KB into a ranked, escalated chemistry decision.")
35
+ app.add_typer(resolve_app, name="resolve")
36
+
37
+ manifest_app = typer.Typer(
38
+ help="The DATASET manifest: what the data IS. Immutable, one per dataset."
39
+ )
40
+ app.add_typer(manifest_app, name="manifest")
41
+ processing_app = typer.Typer(
42
+ help="The PROCESSING manifest: what to DO with a dataset. Many per dataset."
43
+ )
44
+ app.add_typer(processing_app, name="processing")
45
+
46
+ harvest_app = typer.Typer(
47
+ help="Prose/metadata -> span-verified Assertions (the one LLM touchpoint)."
48
+ )
49
+ app.add_typer(harvest_app, name="harvest")
50
+
51
+ eval_app = typer.Typer(help="The evals harness: measure what unit tests cannot (brief §9).")
52
+ app.add_typer(eval_app, name="eval")
53
+
54
+ hook_app = typer.Typer(help="Agent hooks: the rules as mechanism, not aspiration (design §4.2).")
55
+ app.add_typer(hook_app, name="hook")
56
+
57
+ project_app = typer.Typer(
58
+ help="Project-level views over a multi-assay compile (sample_metadata.tsv + project.yaml)."
59
+ )
60
+ app.add_typer(project_app, name="project")
61
+
62
+
63
+ @app.command()
64
+ def version() -> None:
65
+ """Print the seqforge version."""
66
+ typer.echo(__version__)