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,78 @@
1
+ """``evals`` — the harness that stops the compiler rotting invisibly (brief §9).
2
+
3
+ Every other module in seqforge can be pinned by a unit test: same bytes in, same artifact out. Two
4
+ things here cannot.
5
+
6
+ 1. **The LLM stage is nondeterministic.** The same document has produced different (both correct,
7
+ both span-verified) quotes across runs. There is no output to snapshot — only a rate to measure.
8
+ 2. **Prompt and KB edits are silent.** Adding a KB alias or rewording an instruction changes
9
+ extraction behavior without changing a single test. The brief is explicit: treat prompt and KB
10
+ changes as code changes.
11
+
12
+ So this module measures rates over real cases run through the real pipeline, and singles out
13
+ **false-accept** — a confident wrong manifest — as the metric that matters. A refusal costs a human's
14
+ attention; a false accept silently poisons a training corpus and is never questioned again. The
15
+ grading tables in :mod:`.grade` encode that asymmetry rather than treating every failure alike.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ #: CalVer YYYY.M.PATCH; bumped when case format or grading semantics change. A grading change is a
21
+ #: code change — two reports are only comparable at equal EVALS_VERSION.
22
+ EVALS_VERSION = "2026.7.0"
23
+
24
+ from .case import ( # noqa: E402
25
+ Case,
26
+ CaseError,
27
+ CaseSkipped,
28
+ Expected,
29
+ LocalRecipe,
30
+ Materialized,
31
+ RandomRecipe,
32
+ Recipe,
33
+ SpecRecipe,
34
+ default_cases_dir,
35
+ discover_cases,
36
+ load_case,
37
+ materialize,
38
+ )
39
+ from .grade import CaseGrade, FieldCheck, Grade, grade_case, outcome_of # noqa: E402
40
+ from .run import ( # noqa: E402
41
+ CaseRun,
42
+ HarvestGrade,
43
+ build_report,
44
+ load_cases,
45
+ run_case,
46
+ run_cases,
47
+ )
48
+
49
+ __all__ = [
50
+ "EVALS_VERSION",
51
+ # cases
52
+ "Case",
53
+ "CaseError",
54
+ "CaseSkipped",
55
+ "Expected",
56
+ "Recipe",
57
+ "SpecRecipe",
58
+ "RandomRecipe",
59
+ "LocalRecipe",
60
+ "Materialized",
61
+ "load_case",
62
+ "discover_cases",
63
+ "default_cases_dir",
64
+ "materialize",
65
+ # grading
66
+ "Grade",
67
+ "CaseGrade",
68
+ "FieldCheck",
69
+ "grade_case",
70
+ "outcome_of",
71
+ # running
72
+ "run_case",
73
+ "run_cases",
74
+ "build_report",
75
+ "load_cases",
76
+ "CaseRun",
77
+ "HarvestGrade",
78
+ ]
seqforge/evals/case.py ADDED
@@ -0,0 +1,382 @@
1
+ """Eval cases — a declarative, *byte-free* description of a dataset and its ground truth (brief §9).
2
+
3
+ Layout, per the brief::
4
+
5
+ evals/cases/<case_id>/
6
+ inputs/recipe.yaml # HOW to build the FASTQ, not the FASTQ itself
7
+ metadata/*.txt # prose the LLM stage reads (optional)
8
+ expected.yaml # ground truth, or the expected refusal/question
9
+
10
+ **Inputs are a recipe, never committed bytes.** A recipe is a few hundred bytes, is deterministic in
11
+ ``(spec, seed)``, and regenerates byte-identically on any machine — so a case is diffable, a KB spec
12
+ change is *visible* in the inputs it produces, and no FASTQ ever enters git history. It also lets a
13
+ case backed by **real** data (which is far too large for git, and whose path is a lab fact this public
14
+ repo must not carry) use the same format via ``kind: local``: the ground truth is committed, the bytes
15
+ stay wherever the maintainer keeps them.
16
+
17
+ The recipe deliberately reuses ``kb.generate`` — the same round-trip generator the KB self-tests
18
+ run on. Evals therefore measure the compiler, not a second, drifting notion of what a FASTQ looks like.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import random
25
+ from dataclasses import dataclass, replace
26
+ from pathlib import Path
27
+ from typing import Any, Literal
28
+
29
+ import yaml
30
+ from pydantic import BaseModel, ConfigDict, Field
31
+
32
+ from .. import kb
33
+ from ..io import OnlistRegistry
34
+ from ..kb.generate import write_fastq_gz
35
+ from ..models.records import ArchiveRecordSet
36
+
37
+ CASES_DIRNAME = "cases"
38
+
39
+
40
+ class Truncate(BaseModel):
41
+ """Cut a gzip member mid-stream: valid records, then an abrupt end (the TRUNCATED_GZIP negative)."""
42
+
43
+ model_config = ConfigDict(extra="forbid")
44
+
45
+ file: str
46
+ fraction: float = Field(default=0.6, gt=0.0, lt=1.0)
47
+
48
+
49
+ class SpecRecipe(BaseModel):
50
+ """Synthesize inputs from a KB spec via the round-trip generator."""
51
+
52
+ model_config = ConfigDict(extra="forbid")
53
+
54
+ kind: Literal["spec"] = "spec"
55
+ spec: str
56
+ n: int = Field(default=3000, gt=0)
57
+ seed: int = 0
58
+ pool_size: int = Field(default=64, gt=0)
59
+ #: ``synthetic`` registers the exact pools the reads were drawn from (rung 3 reachable);
60
+ #: ``none`` withholds the whitelist, so the case can only be settled by structure (rung <=2).
61
+ onlists: Literal["synthetic", "none"] = "synthetic"
62
+ truncate: Truncate | None = None
63
+
64
+
65
+ class RandomRecipe(BaseModel):
66
+ """Bytes that match no KB technology — the ONT / UNSUPPORTED_TECHNOLOGY negative."""
67
+
68
+ model_config = ConfigDict(extra="forbid")
69
+
70
+ kind: Literal["random"] = "random"
71
+ name: str = "reads"
72
+ n: int = Field(default=200, gt=0)
73
+ min_len: int = Field(default=500, gt=0)
74
+ max_len: int = Field(default=3000, gt=0)
75
+ seed: int = 0
76
+
77
+
78
+ class LocalRecipe(BaseModel):
79
+ """Real files at a path this repo does not contain.
80
+
81
+ ``root`` is resolved from the environment at run time, never committed — the data is too large for
82
+ git and its location is a lab fact, not a project fact. A case whose root is unset or absent
83
+ **skips**: it never fails and never silently passes.
84
+ """
85
+
86
+ model_config = ConfigDict(extra="forbid")
87
+
88
+ kind: Literal["local"] = "local"
89
+ #: Name of the env var holding the dataset root. The value lives in out-of-git config.
90
+ root_env: str
91
+ glob: str = "*.fastq.gz"
92
+ #: Prose that lives WITH the data rather than in the case directory — a glob under ``root``,
93
+ #: e.g. ``info/*.pdf``.
94
+ #:
95
+ #: Without this a local case could not point at a document at all, so ``has_prose`` was false, so
96
+ #: the language model never ran, so the organism could never come from the paper — **the single
97
+ #: thing PRJNA1027859 exists to test**. A synthetic case keeps its prose in ``metadata/``; a real
98
+ #: one cannot, because the paper is 10 MB and lives beside 220 GB of FASTQ, outside the repo.
99
+ docs_glob: str = ""
100
+
101
+
102
+ class Recipe(BaseModel):
103
+ """``inputs/recipe.yaml``."""
104
+
105
+ model_config = ConfigDict(extra="forbid")
106
+
107
+ generate: SpecRecipe | RandomRecipe | LocalRecipe = Field(discriminator="kind")
108
+ #: A metadata claim entering resolve as a hypothesis WITHOUT an LLM, so conflict/steering cases
109
+ #: are testable in a no-API-key CI. When a case has prose and `--llm` is on, harvest overrides it.
110
+ hypothesis: str | None = None
111
+
112
+
113
+ class ExpectedConflict(BaseModel):
114
+ """The conflict a case must surface.
115
+
116
+ ``positions`` is the load-bearing assertion, not ``field``: design §3.5 specifies the conflict by
117
+ the values that disagree (26 bp asserted vs 28 bp observed), because *that* is the decidable pair
118
+ a human is being shown. Asserting only the field name would let both positions collapse to the
119
+ same value and still pass.
120
+ """
121
+
122
+ model_config = ConfigDict(extra="forbid")
123
+
124
+ kind: str = "observed_vs_asserted"
125
+ field: str | None = None
126
+ #: Expected ``basis -> value`` for each position, e.g. ``{asserted: "26", observed: "28"}``.
127
+ positions: dict[str, str] = Field(default_factory=dict)
128
+
129
+
130
+ class ExpectedAssertion(BaseModel):
131
+ """A claim the prose really makes, which harvest must extract AND span-verify."""
132
+
133
+ model_config = ConfigDict(extra="forbid")
134
+
135
+ field: str
136
+ value: str
137
+
138
+
139
+ class Expected(BaseModel):
140
+ """``expected.yaml`` — ground truth, or the expected refusal/question.
141
+
142
+ ``outcome`` is the primary contract; everything else refines it. Note ``forbidden_fields``: prose
143
+ traps where the correct extraction is *silence*. Rewarding only recall would train the prompt to
144
+ guess, which is precisely the failure this harness exists to catch.
145
+ """
146
+
147
+ model_config = ConfigDict(extra="forbid")
148
+
149
+ outcome: Literal["decide", "refuse", "ask"]
150
+ description: str = ""
151
+ #: Which code the expectation was written against — required for a case over real data, meaningless
152
+ #: for a synthetic one.
153
+ #:
154
+ #: A pre-registration mixes two kinds of claim and only one is sacred:
155
+ #:
156
+ #: (a) claims about the DATASET — organism, chemistry, what the record declares. From public
157
+ #: metadata. **Never change these.** Editing one after a run is cheating, full stop.
158
+ #: (b) claims about OUR COMPILER'S OUTPUT on that dataset — a function of code version. Editing
159
+ #: one after a code change is not tuning against the answer; it is keeping a prediction
160
+ #: well-typed.
161
+ #:
162
+ #: This stamp is what makes the difference auditable from `git log` alone: was every (a) claim
163
+ #: byte-identical to the pre-run commit, and did every (b) change cite only a code diff? A (b)
164
+ #: edit derived from a **diff** passes. One derived from a **run** does not. Never overwrite a
165
+ #: (b) claim — append, and let the old prediction stand in git as the dated record.
166
+ predicts: dict[str, str] = Field(default_factory=dict)
167
+ #: Dotted manifest paths -> expected value. Supported: ``library.chemistry``,
168
+ #: ``library.equivalence_members``, ``library.roles.<role_id>`` (value = a file label), ``rung``.
169
+ fields: dict[str, Any] = Field(default_factory=dict)
170
+ #: For ``outcome: refuse`` — the BlockerCodes that must be raised.
171
+ blockers: list[str] = Field(default_factory=list)
172
+ #: For ``outcome: ask`` — the conflict that must be surfaced.
173
+ conflict: ExpectedConflict | None = None
174
+ #: Harvest ground truth (checked only when the LLM stage runs).
175
+ assertions: list[ExpectedAssertion] = Field(default_factory=list)
176
+ #: Fields the prose does NOT state: extracting any of them is a hallucination.
177
+ forbidden_fields: list[str] = Field(default_factory=list)
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class Case:
182
+ id: str
183
+ root: Path
184
+ recipe: Recipe
185
+ expected: Expected
186
+ metadata_docs: list[Path]
187
+ #: `<case>/records.json` — what the archive declares, as `seqforge io records` fetched it.
188
+ #:
189
+ #: Committed rather than fetched at run time, for the same reason the FASTQ is a recipe: a case
190
+ #: must be reproducible and must not need the network. It is public metadata (no lab path,
191
+ #: `test_skill_never_leaks_a_lab_path` still applies), it is an INPUT rather than an expectation,
192
+ #: and it is byte-identical to what `io records` returns today.
193
+ records: ArchiveRecordSet | None = None
194
+
195
+ @property
196
+ def has_prose(self) -> bool:
197
+ return bool(self.metadata_docs) or bool(self.records)
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class Materialized:
202
+ """Inputs on disk, plus the onlist registry the resolver may use."""
203
+
204
+ paths: list[Path]
205
+ registry: OnlistRegistry | None
206
+ #: Label per file basename, e.g. ``R1.fastq.gz`` -> ``R1``, for role-assignment assertions.
207
+ labels: dict[str, str]
208
+ #: The case's archive records, carried through so the metadata resolver gets the same input the
209
+ #: CLI would give it. ``None`` for a case with no accession, which is most of them.
210
+ records: ArchiveRecordSet | None = None
211
+
212
+
213
+ class CaseError(RuntimeError):
214
+ """A case is malformed. Distinct from a case *failing* — this is a bug in the case itself."""
215
+
216
+
217
+ class CaseSkipped(RuntimeError):
218
+ """A case cannot run here (local root unset, LLM needed but disabled). Never a pass or fail."""
219
+
220
+
221
+ def default_cases_dir() -> Path:
222
+ """``<repo>/evals/cases``. The harness ships with the repo, so this is a relative walk-up."""
223
+ return Path(__file__).resolve().parents[3] / "evals" / CASES_DIRNAME
224
+
225
+
226
+ def load_case(root: Path) -> Case:
227
+ """Load one case directory. Raises :class:`CaseError` if the case itself is malformed."""
228
+ root = Path(root)
229
+ expected_path = root / "expected.yaml"
230
+ recipe_path = root / "inputs" / "recipe.yaml"
231
+ if not expected_path.is_file():
232
+ raise CaseError(f"{root.name}: missing expected.yaml")
233
+ if not recipe_path.is_file():
234
+ raise CaseError(f"{root.name}: missing inputs/recipe.yaml")
235
+ try:
236
+ expected = Expected.model_validate(_read_yaml(expected_path))
237
+ recipe = Recipe.model_validate(_read_yaml(recipe_path))
238
+ except Exception as exc: # pydantic/yaml -> a case-level error, not a crash
239
+ raise CaseError(f"{root.name}: {exc}") from exc
240
+
241
+ meta_dir = root / "metadata"
242
+ docs = sorted(p for p in meta_dir.glob("*") if p.is_file()) if meta_dir.is_dir() else []
243
+ docs += _docs_beside_the_data(recipe)
244
+
245
+ records_path = root / "records.json"
246
+ records = (
247
+ ArchiveRecordSet.model_validate_json(records_path.read_text())
248
+ if records_path.is_file()
249
+ else None
250
+ )
251
+ return Case(
252
+ id=root.name,
253
+ root=root,
254
+ recipe=recipe,
255
+ expected=expected,
256
+ metadata_docs=docs,
257
+ records=records,
258
+ )
259
+
260
+
261
+ def _docs_beside_the_data(recipe: Recipe) -> list[Path]:
262
+ """Prose living at a local case's data root (`docs_glob`), if the root is set and present.
263
+
264
+ Silent when the root is unset: the case is about to skip for that reason anyway, and raising here
265
+ would turn "this machine does not have the data" into a load error for every OTHER case in the
266
+ corpus, since `discover_cases` loads them all.
267
+ """
268
+ gen = recipe.generate
269
+ if not isinstance(gen, LocalRecipe) or not gen.docs_glob:
270
+ return []
271
+ root = os.environ.get(gen.root_env)
272
+ if not root or not Path(root).is_dir():
273
+ return []
274
+ return sorted(p for p in Path(root).glob(gen.docs_glob) if p.is_file())
275
+
276
+
277
+ def discover_cases(cases_dir: Path | None = None) -> list[Case]:
278
+ """Every case directory, sorted by id."""
279
+ base = Path(cases_dir) if cases_dir is not None else default_cases_dir()
280
+ if not base.is_dir():
281
+ return []
282
+ return [load_case(d) for d in sorted(base.iterdir()) if d.is_dir()]
283
+
284
+
285
+ def materialize(case: Case, dest: Path) -> Materialized:
286
+ """Build the case's FASTQ inputs under ``dest``. Deterministic in the recipe."""
287
+ gen = case.recipe.generate
288
+ dest.mkdir(parents=True, exist_ok=True)
289
+ if isinstance(gen, LocalRecipe):
290
+ built = _materialize_local(gen)
291
+ elif isinstance(gen, RandomRecipe):
292
+ built = _materialize_random(gen, dest)
293
+ else:
294
+ built = _materialize_spec(gen, dest)
295
+ return replace(built, records=case.records)
296
+
297
+
298
+ def _materialize_local(gen: LocalRecipe) -> Materialized:
299
+ root = os.environ.get(gen.root_env)
300
+ if not root:
301
+ raise CaseSkipped(
302
+ f"${gen.root_env} is not set (a local case's root lives outside the repo)"
303
+ )
304
+ base = Path(root)
305
+ if not base.is_dir():
306
+ raise CaseSkipped(f"${gen.root_env}={root} does not exist on this machine")
307
+ paths = sorted(base.glob(gen.glob))
308
+ if not paths:
309
+ raise CaseSkipped(f"no files matching {gen.glob!r} under ${gen.root_env}")
310
+ return Materialized(paths=paths, registry=None, labels={p.name: _label(p.name) for p in paths})
311
+
312
+
313
+ def _materialize_random(gen: RandomRecipe, dest: Path) -> Materialized:
314
+ rng = random.Random(gen.seed)
315
+ seqs = [
316
+ "".join(rng.choice("ACGT") for _ in range(rng.randint(gen.min_len, gen.max_len)))
317
+ for _ in range(gen.n)
318
+ ]
319
+ path = dest / f"{gen.name}.fastq.gz"
320
+ _write_fastq_gz(path, seqs)
321
+ return Materialized(paths=[path], registry=None, labels={path.name: gen.name})
322
+
323
+
324
+ def _materialize_spec(gen: SpecRecipe, dest: Path) -> Materialized:
325
+ try:
326
+ spec = kb.load_spec(gen.spec)
327
+ except Exception as exc:
328
+ raise CaseError(f"unknown KB spec {gen.spec!r}: {exc}") from exc
329
+
330
+ pools = kb.build_pools(spec, seed=gen.seed, pool_size=gen.pool_size)
331
+ reads = kb.generate_reads(spec, n=gen.n, seed=gen.seed, pool_size=gen.pool_size, pools=pools)
332
+
333
+ paths: list[Path] = []
334
+ labels: dict[str, str] = {}
335
+ for read_id, seqs in reads.items():
336
+ path = dest / f"{read_id}.fastq.gz"
337
+ _write_fastq_gz(path, seqs)
338
+ paths.append(path)
339
+ labels[path.name] = read_id
340
+
341
+ if gen.truncate is not None:
342
+ target = dest / f"{gen.truncate.file}.fastq.gz"
343
+ if not target.is_file():
344
+ raise CaseError(
345
+ f"truncate.file={gen.truncate.file!r} is not a read of spec {gen.spec!r} "
346
+ f"(have: {sorted(reads)})"
347
+ )
348
+ data = target.read_bytes()
349
+ target.write_bytes(data[: int(len(data) * gen.truncate.fraction)])
350
+
351
+ registry: OnlistRegistry | None = None
352
+ if gen.onlists == "synthetic":
353
+ registry = OnlistRegistry(offline=True)
354
+ for alias, ref in spec.onlists.items():
355
+ if alias in pools:
356
+ registry.register_synthetic(ref.registry, pools[alias])
357
+ return Materialized(paths=paths, registry=registry, labels=labels)
358
+
359
+
360
+ def _label(basename: str) -> str:
361
+ name = basename
362
+ for suffix in (".gz", ".fastq", ".fq"):
363
+ if name.endswith(suffix):
364
+ name = name[: -len(suffix)]
365
+ return name
366
+
367
+
368
+ def _write_fastq_gz(path: Path, seqs: list[str]) -> None:
369
+ """The KB's reproducible writer: identical recipe -> identical bytes -> identical sha256.
370
+
371
+ Reproducibility is what makes a recipe a legitimate stand-in for the bytes it replaces, so this
372
+ module must not grow its own writer. See :func:`kb.generate.write_fastq_gz` for why a plain
373
+ ``gzip.open`` is not reproducible.
374
+ """
375
+ write_fastq_gz(path, seqs)
376
+
377
+
378
+ def _read_yaml(path: Path) -> dict[str, Any]:
379
+ data = yaml.safe_load(path.read_text())
380
+ if not isinstance(data, dict):
381
+ raise CaseError(f"{path.name}: expected a YAML mapping, got {type(data).__name__}")
382
+ return data