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/hooks/guards.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Hook guards — the rules turned into mechanism (design §4.2).
|
|
2
|
+
|
|
3
|
+
`CLAUDE.md` says never read a whole FASTQ, no absolute paths in a manifest, and refusal is an exit
|
|
4
|
+
code. Written down, those are aspirations. Here they become something that actually stops a tool call.
|
|
5
|
+
|
|
6
|
+
**The logic lives here, not in a shell script, for one reason: a guard that silently never fires is
|
|
7
|
+
indistinguishable from a guard that always allows — and that is the worst possible failure for a
|
|
8
|
+
safety check.** So the decisions are pure functions with types and tests, and the CLI is a thin
|
|
9
|
+
stdin/stdout shim over them. Payload parsing is deliberately forgiving (several key spellings are
|
|
10
|
+
accepted) because a hook that misreads one field name fails open, quietly, forever.
|
|
11
|
+
|
|
12
|
+
Three events, three jobs:
|
|
13
|
+
|
|
14
|
+
- ``PreToolUse`` — deny an unbounded FASTQ stream, and deny writing an absolute path into a manifest.
|
|
15
|
+
- ``PostToolUse`` — re-run ``manifest validate`` after any manifest edit; the model does not get to
|
|
16
|
+
decide whether its own edit was valid.
|
|
17
|
+
- ``Stop`` — refuse to end a turn while ``questions.md`` is non-empty; exit 4 and this hook are
|
|
18
|
+
the only ways ambiguity clears, and both route to a human.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
import shlex
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from ..workspace import state_dir
|
|
30
|
+
|
|
31
|
+
#: CalVer YYYY.M.PATCH. Bump when a guard's semantics change.
|
|
32
|
+
#: 2026.7.1 — the held-out guard is REMOVED. `PRJNA1027859` became the pilot's demo dataset, so
|
|
33
|
+
#: reserving it was a misunderstanding rather than a rule worth keeping; the `SEQFORGE_CASE_*` root
|
|
34
|
+
#: registry went with it. Deleted, not disabled: a guard nobody wants should not linger as dead code
|
|
35
|
+
#: that a future reader mistakes for protection.
|
|
36
|
+
HOOKS_VERSION = "2026.7.1"
|
|
37
|
+
|
|
38
|
+
#: Readers that will happily stream a 40 GB file to stdout.
|
|
39
|
+
_STREAMERS = ("cat", "zcat", "gzcat", "bzcat", "xzcat", "gunzip", "zless", "zmore")
|
|
40
|
+
|
|
41
|
+
#: A bound of any kind. `head`/`tail` cap the stream; the seqforge verbs are bounded by construction.
|
|
42
|
+
_BOUNDED = re.compile(
|
|
43
|
+
r"(\|\s*(head|tail)\b)|(\bhead\s+-[nc])|(\btail\s+-[nc])|(--max-reads\b)|(--max-bytes\b)|(\bsed\s+-n\b.*\bq\b)",
|
|
44
|
+
re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_FASTQ = re.compile(r"[^\s'\"]+\.(fastq|fq)(\.gz|\.bz2|\.xz)?\b", re.IGNORECASE)
|
|
48
|
+
|
|
49
|
+
_ABS_PATH = re.compile(r"(?<![\w.])/(?:[A-Za-z0-9._-]+/)+[A-Za-z0-9._-]+")
|
|
50
|
+
|
|
51
|
+
#: A URI is what the manifest *wants* for data, but `s3://bucket/x.fastq.gz` contains `/bucket/x.fastq.gz`,
|
|
52
|
+
#: which looks exactly like an absolute path. Scrub URIs before the path scan or the guard rejects
|
|
53
|
+
#: the very manifests it exists to encourage — and a guard that blocks correct work gets switched off.
|
|
54
|
+
_URI = re.compile(r"\b[a-z][a-z0-9+.\-]*://\S+", re.IGNORECASE)
|
|
55
|
+
|
|
56
|
+
#: Manifest/config files the machine-independent-path rule applies to.
|
|
57
|
+
_MANIFEST_FILE = re.compile(r"(manifest[^/]*\.ya?ml|config\.ya?ml|units\.tsv)$", re.IGNORECASE)
|
|
58
|
+
|
|
59
|
+
#: Keys a manifest legitimately carries that look like paths but are not (assembly ids, env names).
|
|
60
|
+
_ALLOWED_ABS_PREFIXES = ("/dev/null",)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Denial:
|
|
65
|
+
"""A refused tool call. ``remedy`` must be actionable — a block with no way forward is a wall."""
|
|
66
|
+
|
|
67
|
+
rule: str
|
|
68
|
+
reason: str
|
|
69
|
+
remedy: str
|
|
70
|
+
|
|
71
|
+
def message(self) -> str:
|
|
72
|
+
return f"{self.rule}: {self.reason}\nRemedy: {self.remedy}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_seqforge_command(command: str) -> bool:
|
|
76
|
+
"""Is this a sanctioned seqforge verb? Those are bounded by construction and are the API.
|
|
77
|
+
|
|
78
|
+
This is the line the guard draws: `seqforge probe` is the sanctioned, bounded, auditable path.
|
|
79
|
+
`cat` on the same file is the leak. Blocking both would make the tool unusable; blocking neither
|
|
80
|
+
would make the rule decorative.
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
parts = shlex.split(command)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return False
|
|
86
|
+
for i, tok in enumerate(parts):
|
|
87
|
+
if tok in ("&&", "||", "|", ";"):
|
|
88
|
+
continue
|
|
89
|
+
if tok.endswith("seqforge"):
|
|
90
|
+
return True
|
|
91
|
+
# `pixi run -- seqforge ...` / `python -m seqforge.cli ...`
|
|
92
|
+
if tok == "seqforge" or (tok == "-m" and i + 1 < len(parts) and "seqforge" in parts[i + 1]):
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def check_unbounded_fastq(command: str) -> Denial | None:
|
|
98
|
+
"""A code path that CAN stream a whole multi-GB FASTQ is a bug, not a risk to manage."""
|
|
99
|
+
if not command or _is_seqforge_command(command):
|
|
100
|
+
return None
|
|
101
|
+
hits = _FASTQ.findall(command)
|
|
102
|
+
if not hits:
|
|
103
|
+
return None
|
|
104
|
+
if not any(re.search(rf"\b{s}\b", command) for s in _STREAMERS):
|
|
105
|
+
return None
|
|
106
|
+
if _BOUNDED.search(command):
|
|
107
|
+
return None
|
|
108
|
+
return Denial(
|
|
109
|
+
rule="never read a whole FASTQ",
|
|
110
|
+
reason=(
|
|
111
|
+
"this streams a FASTQ with no read/byte bound. Wall-clock is not a budget: the file may "
|
|
112
|
+
"be 40 GB and the command would happily read all of it."
|
|
113
|
+
),
|
|
114
|
+
remedy=(
|
|
115
|
+
"use `seqforge probe FILE --json` (bounded: 200k reads / 256 MB by construction), or if "
|
|
116
|
+
"you truly need shell, bound it explicitly — e.g. `zcat f.fastq.gz | head -n 4000`."
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def check_absolute_path_write(file_path: str, content: str) -> Denial | None:
|
|
122
|
+
"""A manifest with a machine-specific path is not a manifest, it is a note to one machine."""
|
|
123
|
+
if not file_path or not _MANIFEST_FILE.search(file_path):
|
|
124
|
+
return None
|
|
125
|
+
scrubbed = _URI.sub(" ", content or "") # a URI is the RIGHT answer here, not a violation
|
|
126
|
+
for match in _ABS_PATH.finditer(scrubbed):
|
|
127
|
+
path = match.group(0)
|
|
128
|
+
if path.startswith(_ALLOWED_ABS_PREFIXES):
|
|
129
|
+
continue
|
|
130
|
+
return Denial(
|
|
131
|
+
rule="machine-independent manifest",
|
|
132
|
+
reason=(
|
|
133
|
+
f"{Path(file_path).name} would carry the absolute path {path!r}. The manifest must "
|
|
134
|
+
"resolve on any machine; a baked path silently pins it to this one."
|
|
135
|
+
),
|
|
136
|
+
remedy=(
|
|
137
|
+
"reference a genome by UCSC assembly id + registered GTF name (liulab-genome), an "
|
|
138
|
+
"environment by its literal liulab-runtime name, and data by a URI."
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _tool_input(payload: dict[str, Any]) -> dict[str, Any]:
|
|
145
|
+
value = payload.get("tool_input") or payload.get("toolInput") or {}
|
|
146
|
+
return value if isinstance(value, dict) else {}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _content_of(tool_input: dict[str, Any]) -> str:
|
|
150
|
+
"""Write/Edit spell the payload differently; a missed key here would fail OPEN, so take them all."""
|
|
151
|
+
for key in ("file_text", "content", "new_string", "new_str"):
|
|
152
|
+
value = tool_input.get(key)
|
|
153
|
+
if isinstance(value, str):
|
|
154
|
+
return value
|
|
155
|
+
return ""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def pre_tool_use(payload: dict[str, Any]) -> Denial | None:
|
|
159
|
+
"""Decide a PreToolUse call. ``None`` == no opinion (the normal permission flow still applies)."""
|
|
160
|
+
tool = str(payload.get("tool_name") or payload.get("toolName") or "")
|
|
161
|
+
tool_input = _tool_input(payload)
|
|
162
|
+
|
|
163
|
+
if tool == "Bash":
|
|
164
|
+
return check_unbounded_fastq(str(tool_input.get("command") or ""))
|
|
165
|
+
|
|
166
|
+
if tool in ("Write", "Edit", "NotebookEdit"):
|
|
167
|
+
file_path = str(tool_input.get("file_path") or tool_input.get("path") or "")
|
|
168
|
+
return check_absolute_path_write(file_path, _content_of(tool_input))
|
|
169
|
+
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def post_tool_use_targets(payload: dict[str, Any]) -> str | None:
|
|
174
|
+
"""The manifest a PostToolUse call just edited, if any — else ``None``.
|
|
175
|
+
|
|
176
|
+
In one line: the model does not get to decide whether its own edit was valid. If it touched a
|
|
177
|
+
manifest, `manifest validate` runs, and the exit code (not the model's opinion) is the verdict.
|
|
178
|
+
"""
|
|
179
|
+
tool = str(payload.get("tool_name") or payload.get("toolName") or "")
|
|
180
|
+
if tool not in ("Write", "Edit", "NotebookEdit"):
|
|
181
|
+
return None
|
|
182
|
+
file_path = str(_tool_input(payload).get("file_path") or "")
|
|
183
|
+
if file_path and re.search(r"manifest[^/]*\.ya?ml$", file_path, re.IGNORECASE):
|
|
184
|
+
return file_path
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def questions_outstanding(workspace: Path) -> list[Path]:
|
|
189
|
+
"""Every non-empty ``questions.md`` under ``.seqforge/`` — the open-ambiguity ledger."""
|
|
190
|
+
state = state_dir(workspace)
|
|
191
|
+
if not state.is_dir():
|
|
192
|
+
return []
|
|
193
|
+
return [p for p in sorted(state.rglob("questions.md")) if p.read_text().strip()]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def stop_decision(payload: dict[str, Any], *, workspace: Path) -> str | None:
|
|
197
|
+
"""Refuse to end the turn while a question is open. Returns a reason, or ``None`` to allow.
|
|
198
|
+
|
|
199
|
+
Guarded by ``stop_hook_active``: once the runtime says it has already blocked repeatedly, this
|
|
200
|
+
MUST allow. A hook that blocks forever is not a safety feature, it is a hang — and the failure
|
|
201
|
+
mode of "the agent can never finish" is worse than the one being prevented.
|
|
202
|
+
"""
|
|
203
|
+
if payload.get("stop_hook_active") or payload.get("stopHookActive"):
|
|
204
|
+
return None
|
|
205
|
+
open_files = questions_outstanding(workspace)
|
|
206
|
+
if not open_files:
|
|
207
|
+
return None
|
|
208
|
+
names = ", ".join(str(p) for p in open_files)
|
|
209
|
+
return (
|
|
210
|
+
f"{len(open_files)} open question file(s) remain: {names}. Ambiguity clears exactly two ways "
|
|
211
|
+
"— a human answers, or code decides — and neither has happened. Answer them (then "
|
|
212
|
+
"`seqforge resolve apply`), or say plainly that the dataset needs a human. Do not guess: an "
|
|
213
|
+
"unanswered question that gets quietly resolved is how a wrong manifest reaches the corpus."
|
|
214
|
+
)
|
seqforge/io/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""``io`` — the only network + onlist surface (pooch-cached, sha256-verified).
|
|
2
|
+
|
|
3
|
+
Milestone 0 ships the **onlist registry** (``resolve``'s Tier B needs it): width-generic packed
|
|
4
|
+
whitelists, a hit-rate test (forward + reverse-complement + offset scan), set-intersection for the
|
|
5
|
+
confusability check, and a synthetic-onlist path for the pilot fixtures. The remote surface is live:
|
|
6
|
+
``resolve_accession`` (accession -> run inventory), ``peek`` (bounded range-read diagnostic), and
|
|
7
|
+
``probe_remote`` (range-read -> :class:`~seqforge.models.observation.Observation`, so a library is
|
|
8
|
+
fingerprinted straight from a URL with no local file, issue #39) — see :mod:`seqforge.io.remote`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .onlist import (
|
|
14
|
+
DEFAULT_REGISTRY,
|
|
15
|
+
HitResult,
|
|
16
|
+
OnlistNotAvailable,
|
|
17
|
+
OnlistRegistry,
|
|
18
|
+
Orientation,
|
|
19
|
+
PackedOnlist,
|
|
20
|
+
RegistryEntry,
|
|
21
|
+
default_registry,
|
|
22
|
+
intersect_fraction,
|
|
23
|
+
onlist_hit_rate,
|
|
24
|
+
pack_barcode,
|
|
25
|
+
revcomp,
|
|
26
|
+
synthetic_registry,
|
|
27
|
+
)
|
|
28
|
+
from .remote import (
|
|
29
|
+
NotYetImplemented,
|
|
30
|
+
fastq_targets,
|
|
31
|
+
peek,
|
|
32
|
+
probe_remote,
|
|
33
|
+
resolve_accession,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
#: CalVer YYYY.M.PATCH; bump when onlist packing / registry semantics change.
|
|
37
|
+
IO_VERSION = "2026.7.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"IO_VERSION",
|
|
41
|
+
# onlist registry + packing
|
|
42
|
+
"OnlistRegistry",
|
|
43
|
+
"RegistryEntry",
|
|
44
|
+
"PackedOnlist",
|
|
45
|
+
"HitResult",
|
|
46
|
+
"Orientation",
|
|
47
|
+
"OnlistNotAvailable",
|
|
48
|
+
"onlist_hit_rate",
|
|
49
|
+
"intersect_fraction",
|
|
50
|
+
"pack_barcode",
|
|
51
|
+
"revcomp",
|
|
52
|
+
"synthetic_registry",
|
|
53
|
+
"default_registry",
|
|
54
|
+
"DEFAULT_REGISTRY",
|
|
55
|
+
# remote range-read + accession resolution
|
|
56
|
+
"peek",
|
|
57
|
+
"probe_remote",
|
|
58
|
+
"fastq_targets",
|
|
59
|
+
"resolve_accession",
|
|
60
|
+
"NotYetImplemented",
|
|
61
|
+
]
|