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,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
+ )
@@ -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
+ ]