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,194 @@
1
+ """Organism name -> NCBI taxid, with a round-trip verifier. **No model is involved, deliberately.**
2
+
3
+ This is the one gap in "the organism must come from the paper" that survived every audit, and the
4
+ shape of it is worth stating because it looks like a job for a language model and is not:
5
+
6
+ harvest extracts experiment.organism = "Caenorhabditis elegans" (a str, with a verified span)
7
+ fill_manifest wants experiment.organism_taxid = 6239 (an int)
8
+ and nothing joined them, so the taxid had to be retyped by hand.
9
+
10
+ The model already does its job here — it *finds* the organism in the prose, span-verified. What was
11
+ missing is a converter, and a converter is a lookup table. A model asked for the taxid directly would
12
+ be asked to *recall* one, and a model confusing 6239 (C. elegans) with 6238 (C. briggsae) produces a
13
+ number that is well-formed, plausible, and wrong in a way nothing downstream can see: a worm dataset
14
+ aligned to a different worm maps at a rate that looks merely mediocre. That is the failure class this
15
+ project exists to prevent, so: **the LLM finds, code resolves.**
16
+
17
+ **The verifier is a round trip, and it is what makes this safe.** Resolve the name to a taxid, then
18
+ fetch that taxid back and confirm it answers to the name we started from (scientific name, synonym, or
19
+ common name). A wrong lookup does not survive it. Note this would make even a *model-proposed* taxid
20
+ safe — the criterion is "cheaply verifiable", not "hard to hardcode" — but a table is simpler and free,
21
+ so there is no reason to reach for one.
22
+
23
+ Two things this module refuses to do:
24
+
25
+ - **Guess a rank.** `Saccharomyces cerevisiae` resolves to 4932 (the species). The sacCer3 fixtures use
26
+ 559292 (strain S288C). Both are correct answers to different questions, and NCBI's search returns the
27
+ species — so when a caller wants the strain, they say so. Silently promoting or demoting a rank is
28
+ the kind of helpfulness that ends up in a corpus.
29
+ - **Reach the network without saying so.** The seed table below covers what we ship fixtures for. A
30
+ name outside it needs one E-utilities call, and offline that is a refusal with an actionable remedy,
31
+ never a guess.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import os
38
+ import re
39
+ import time
40
+ import urllib.error
41
+ import urllib.parse
42
+ import urllib.request
43
+ from dataclasses import dataclass
44
+ from pathlib import Path
45
+
46
+ from ..workspace import cache_dir
47
+ from .remote import _MAX_RETRIES, _RETRY_STATUS, retry_delay
48
+
49
+ _EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
50
+
51
+ #: Names we resolve without a network call. **Every entry was verified against NCBI E-utilities on
52
+ #: 2026-07-15 by the round trip this module implements** — none is remembered.
53
+ #:
54
+ #: This is a cache, not a curation: it exists so the common path is offline and instant, not to decide
55
+ #: which organisms are worth supporting (that would be a policy hiding in a dict). Anything else does
56
+ #: one lookup. Keys are lowercased.
57
+ _SEED: dict[str, int] = {
58
+ "caenorhabditis elegans": 6239,
59
+ "c. elegans": 6239,
60
+ "homo sapiens": 9606,
61
+ "human": 9606,
62
+ "mus musculus": 10090,
63
+ "mouse": 10090,
64
+ "saccharomyces cerevisiae": 4932,
65
+ "saccharomyces cerevisiae s288c": 559292,
66
+ "danio rerio": 7955,
67
+ "drosophila melanogaster": 7227,
68
+ }
69
+
70
+
71
+ class TaxonomyUnavailable(RuntimeError):
72
+ """A name could not be resolved (unknown, offline, or the round trip failed)."""
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Taxon:
77
+ """A resolved organism: the taxid, plus what NCBI says it is."""
78
+
79
+ taxid: int
80
+ scientific_name: str
81
+ rank: str = ""
82
+ names: tuple[str, ...] = ()
83
+
84
+ def answers_to(self, name: str) -> bool:
85
+ """Does this taxon go by ``name``? The round-trip check's actual question."""
86
+ wanted = _normalize(name)
87
+ return wanted in {_normalize(n) for n in (self.scientific_name, *self.names)}
88
+
89
+
90
+ def _normalize(name: str) -> str:
91
+ """Casefold and collapse whitespace. Nothing cleverer: this is a key, not a parser."""
92
+ return re.sub(r"\s+", " ", name).strip().lower()
93
+
94
+
95
+ def _with_api_key(url: str) -> str:
96
+ """Append ``api_key`` to a eutils URL when the environment sets ``NCBI_API_KEY`` (lifts the keyless
97
+ 3 req/sec cap to 10). Only eutils URLs — the key is NCBI's and belongs on no other host (#9)."""
98
+ key = os.environ.get("NCBI_API_KEY")
99
+ if not key or not url.startswith(_EUTILS):
100
+ return url
101
+ sep = "&" if "?" in url else "?"
102
+ return f"{url}{sep}api_key={urllib.parse.quote(key)}"
103
+
104
+
105
+ def _get(url: str, *, timeout: float) -> str:
106
+ # Mirror `remote._get`: a transient status (429 rate-limit, a 5xx blip) backs off and retries
107
+ # rather than aborting the lookup. taxonomy uses urllib, so a non-2xx arrives as an HTTPError.
108
+ full = _with_api_key(url)
109
+ attempt = 0
110
+ while True: # exits only by return (2xx) or raise (terminal status / exhausted budget)
111
+ try:
112
+ with urllib.request.urlopen(full, timeout=timeout) as fh: # noqa: S310 - pinned NCBI host
113
+ return str(fh.read().decode())
114
+ except urllib.error.HTTPError as exc:
115
+ if exc.code in _RETRY_STATUS and attempt < _MAX_RETRIES:
116
+ time.sleep(retry_delay(exc.headers.get("Retry-After"), attempt))
117
+ attempt += 1
118
+ continue
119
+ raise
120
+
121
+
122
+ def fetch_taxon(taxid: int, *, timeout: float = 20.0) -> Taxon:
123
+ """What NCBI says taxid ``taxid`` is. The second half of the round trip."""
124
+ xml = _get(f"{_EUTILS}/efetch.fcgi?db=taxonomy&id={taxid}&retmode=xml", timeout=timeout)
125
+ sci = re.search(r"<ScientificName>(.*?)</ScientificName>", xml)
126
+ if not sci:
127
+ raise TaxonomyUnavailable(f"NCBI returned no scientific name for taxid {taxid}")
128
+ rank = re.search(r"<Rank>(.*?)</Rank>", xml)
129
+ names = tuple(
130
+ re.findall(r"<Synonym>(.*?)</Synonym>", xml)
131
+ + re.findall(r"<GenbankCommonName>(.*?)</GenbankCommonName>", xml)
132
+ + re.findall(r"<CommonName>(.*?)</CommonName>", xml)
133
+ )
134
+ return Taxon(
135
+ taxid=int(taxid),
136
+ scientific_name=sci.group(1),
137
+ rank=rank.group(1) if rank else "",
138
+ names=names,
139
+ )
140
+
141
+
142
+ def resolve(name: str, *, offline: bool = False, timeout: float = 20.0, verify: bool = True) -> int:
143
+ """``"Caenorhabditis elegans"`` -> ``6239``. Raises rather than guessing.
144
+
145
+ ``verify`` runs the round trip: the taxid is fetched back and must answer to ``name``. It is on by
146
+ default and should stay on — a lookup that returns the wrong taxid is otherwise undetectable, and
147
+ the whole reason this is code instead of a model is that we can check it.
148
+ """
149
+ key = _normalize(name)
150
+ if key in _SEED:
151
+ return _SEED[key]
152
+ if offline:
153
+ raise TaxonomyUnavailable(
154
+ f"cannot resolve organism {name!r} offline. Pass the NCBI taxid directly "
155
+ f"(`--organism <taxid>`), or run with network access."
156
+ )
157
+ term = urllib.parse.quote(name)
158
+ try:
159
+ payload = json.loads(
160
+ _get(f"{_EUTILS}/esearch.fcgi?db=taxonomy&term={term}&retmode=json", timeout=timeout)
161
+ )
162
+ ids = payload["esearchresult"]["idlist"]
163
+ except Exception as exc: # network, JSON, or shape - all mean "we do not know"
164
+ raise TaxonomyUnavailable(f"NCBI taxonomy lookup for {name!r} failed: {exc}") from exc
165
+ if not ids:
166
+ raise TaxonomyUnavailable(
167
+ f"NCBI taxonomy has no match for organism {name!r}. Check the spelling, or pass the "
168
+ f"taxid directly with `--organism <taxid>`."
169
+ )
170
+ if len(ids) > 1:
171
+ raise TaxonomyUnavailable(
172
+ f"organism {name!r} is ambiguous: NCBI returns taxids {ids}. Pass the one you mean with "
173
+ f"`--organism <taxid>` — picking for you is exactly the guess this refuses to make."
174
+ )
175
+ taxid = int(ids[0])
176
+ if verify:
177
+ taxon = fetch_taxon(taxid, timeout=timeout)
178
+ if not taxon.answers_to(name):
179
+ raise TaxonomyUnavailable(
180
+ f"round trip failed: {name!r} resolved to taxid {taxid}, but NCBI says that is "
181
+ f"{taxon.scientific_name!r} (rank {taxon.rank}). Refusing — a wrong organism aligns "
182
+ f"at a rate that merely looks mediocre, and nothing downstream would object."
183
+ )
184
+ return taxid
185
+
186
+
187
+ def seed_names() -> dict[str, int]:
188
+ """The offline table, for tests and `io taxon list`."""
189
+ return dict(_SEED)
190
+
191
+
192
+ #: Where a resolved lookup is cached under a workspace, so a second run is offline and instant.
193
+ def cache_path(workspace: str | Path) -> Path:
194
+ return cache_dir(workspace) / "taxonomy.json"
@@ -0,0 +1,62 @@
1
+ """``kb`` — the executable, self-testing knowledge base.
2
+
3
+ One directory per technology under ``specs/`` (``spec.yaml`` + ``README.md``). Each spec validates
4
+ against :class:`~seqforge.kb.schema.Spec`, generates its own synthetic fixtures, and round-trips
5
+ through the probe. ``KB_VERSION`` (CalVer) is folded into dataset-level cache keys.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .generate import build_pools, generate_reads
11
+ from .loader import (
12
+ KbTree,
13
+ build_tree,
14
+ list_spec_ids,
15
+ load_all_specs,
16
+ load_spec,
17
+ load_tree,
18
+ runnable_spec_ids,
19
+ )
20
+ from .roundtrip import run_roundtrip
21
+ from .schema import Spec
22
+
23
+ #: CalVer YYYY.M.PATCH; bump when spec semantics change. Folded into dataset candidate cache keys.
24
+ #: 2026.7.3 — 10x 3' v2/v3/v3.1 accept an OVER-LENGTH barcode read (R1 max_len null +
25
+ #: segment_length over_length_min 100) and add soloBarcodeReadLength:0; v2<->v3 and v2<->v3.1 declared
26
+ #: processing_divergent (whitelist-decided) for the over-length case length can no longer separate.
27
+ #: (over_length_min is 100, not 40, so a 60-94 bp cDNA/split-pool read is not mistaken for an
28
+ #: over-sequenced barcode read -> the rung-0-2 separability guard stays green without over-declaring.)
29
+ #: 2026.7.2 — bulk-rnaseq-pe <-> splitseq declared processing_divergent, distinguishable_by onlist.
30
+ #: Found by the new rung-0-2 separability guard on its first run: the generic paired-end fallback
31
+ #: accepts SPLiT-seq's cdna+bc pair on geometry alone and had declared nothing.
32
+ #: 2026.7.1 — the parse/count line: soloFeatures / quantMode / outSAMtype left backend.params,
33
+ #: which now declares ONLY byte-decided parse keys. Also adds the 10x-3p-gex-v3.1 benign twin.
34
+ #: 2026.7.4 — added the bd-rhapsody-wta spec (BD Rhapsody WTA, original fixed-offset cell-label bead:
35
+ #: CB_UMI_Complex, three 97 x 9 bp CLS whitelists SHIPPED, two fixed linkers). bulk-rnaseq-pe <->
36
+ #: bd-rhapsody-wta declared processing_divergent, distinguishable_by onlist (same rung-0-2 collision
37
+ #: SPLiT-seq has with the generic paired-end fallback).
38
+ #: 2026.7.5 — the KB became a TREE: added the abstract family node 10x-3p-gex (node_kind: family, no
39
+ #: backend, children_decided_by: [onlist]) that recognizes v2/v3/v3.1 and rejects bulk; the three 10x
40
+ #: leaves gained parent: 10x-3p-gex and dropped their divergent-sibling confusable_with cliques (the
41
+ #: shared parent now implies them); v3<->v3.1 processing_equivalent edges kept. Descent narrows to a
42
+ #: length-feasible pool before scoring, so adding the Nth 10x chemistry is one parent link, not a clique.
43
+ #: 2026.7.6 — first ANCHORED chemistry: the bd-rhapsody-wta-enhanced family + leaves -96 / -v2 for the
44
+ #: BD Rhapsody Enhanced bead (variable 0-3 bp diversity insert). New `diversity` element type + per-
45
+ #: element anchors; the family recognizes the GTGA/GACA frame by motif; leaves split on onlist (97 vs
46
+ #: 384 CLS pools, disjoint). Ships bd-rhapsody-cls{1,2,3}-384; -96 reuses the original bead's lists.
47
+ KB_VERSION = "2026.7.6"
48
+
49
+ __all__ = [
50
+ "KB_VERSION",
51
+ "Spec",
52
+ "KbTree",
53
+ "load_spec",
54
+ "load_all_specs",
55
+ "load_tree",
56
+ "build_tree",
57
+ "list_spec_ids",
58
+ "runnable_spec_ids",
59
+ "generate_reads",
60
+ "build_pools",
61
+ "run_roundtrip",
62
+ ]
seqforge/kb/anchor.py ADDED
@@ -0,0 +1,169 @@
1
+ """Per-read window resolution for **anchored / variable-position** elements (design §2.1, §9/§10).
2
+
3
+ Most chemistries put every element at a fixed ``[start, end)`` and the scorer slices a constant column.
4
+ A few do not: BD Rhapsody's *Enhanced* bead prepends a **variable 0-3 bp diversity insert** to Read 1,
5
+ so every cell-label block downstream **staggers** by a per-read amount and no single column holds a
6
+ barcode. STARsolo handles this with an *adapter anchor* (find the ``GTGA…GACA`` linker frame, read the
7
+ barcodes relative to it); this module is seqforge's byte-side twin of that idea.
8
+
9
+ ``resolve_windows(seq, read)`` locates each element's ``[start, end)`` **in one read** by
10
+ **phase detection**: the only unknowns are the lengths of the variable-length elements (the insert),
11
+ so enumerate their candidate lengths, walk the element chain for each, and keep the phase whose
12
+ ``linker``/``fixed`` elements Hamming-match their declared ``sequence`` (substitutions only, exactly
13
+ the error model rhapsodist uses across its 0-3 bp offset scan). A read whose frame cannot be found
14
+ (a cDNA read, a garbage read) returns ``None`` and simply does not contribute — never a wrong slice.
15
+
16
+ This lives in ``kb`` (not ``resolve`` or ``probe``) on purpose: it is pure layout logic over a
17
+ ``kb.schema.Read`` and a string, and both ``kb.roundtrip`` (which cannot import ``resolve`` — that is a
18
+ cycle) and ``resolve.window`` need it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from itertools import product
24
+
25
+ from .schema import Element, Read
26
+
27
+ #: Substitutions tolerated when Hamming-matching a linker whose element declares no ``anchor``
28
+ #: ``max_mismatch``. One per short linker absorbs ordinary sequencing error without letting a wrong
29
+ #: frame match (two independent linkers must BOTH match at the right spacing to lock a phase).
30
+ _DEFAULT_LINKER_MAX_MISMATCH = 1
31
+
32
+
33
+ def has_anchored_elements(read: Read) -> bool:
34
+ """True iff any element on this read floats (declares an ``anchor``) — the resolver's trigger."""
35
+ return any(el.anchor is not None for el in read.elements)
36
+
37
+
38
+ def _fixed_width(el: Element) -> int | None:
39
+ """The element's width when it is known up front; ``None`` for a variable-length / open element."""
40
+ if el.start is not None and el.end is not None:
41
+ return el.end - el.start
42
+ if el.sequence is not None:
43
+ return len(el.sequence)
44
+ if el.min_len is not None and el.min_len == el.max_len:
45
+ return el.min_len
46
+ return None
47
+
48
+
49
+ def _variable_elements(read: Read) -> list[Element]:
50
+ """Elements whose length is an unknown to solve — a declared ``[min_len, max_len]`` with min<max.
51
+
52
+ These are the phase's degrees of freedom (BD Enhanced has exactly one: the 0-3 bp insert). An
53
+ open-ended cDNA/gDNA tail is NOT one of these — it has no upstream effect on the barcode frame, so
54
+ it is resolved last, running to the end of the read.
55
+ """
56
+ out: list[Element] = []
57
+ for el in read.elements:
58
+ if el.type in ("cdna", "gdna") and el.end is None:
59
+ continue
60
+ if el.min_len is not None and el.max_len is not None and el.min_len < el.max_len:
61
+ out.append(el)
62
+ return out
63
+
64
+
65
+ def _candidate_assignments(read: Read, *, cap: int = 256) -> list[dict[str, int]]:
66
+ """Every combination of lengths for the variable elements, as ``{element_name: length}``.
67
+
68
+ Capped so a pathological spec cannot make resolution exponential; BD Enhanced yields 4 candidates
69
+ (insert length 0-3). Returns ``[{}]`` (the single empty assignment) when nothing is variable, so a
70
+ purely motif-anchored fixed-width chemistry still resolves.
71
+ """
72
+ variable = _variable_elements(read)
73
+ if not variable:
74
+ return [{}]
75
+ ranges = [range(el.min_len or 0, (el.max_len or 0) + 1) for el in variable]
76
+ names = [el.name for el in variable]
77
+ combos = list(product(*ranges))
78
+ if len(combos) > cap:
79
+ combos = combos[:cap]
80
+ return [dict(zip(names, lengths, strict=True)) for lengths in combos]
81
+
82
+
83
+ def _anchor_start(el: Element, windows: dict[str, tuple[int, int]], prev_end: int) -> int:
84
+ """Where this element begins, from its ``anchor`` (falling back to the previous element's end).
85
+
86
+ The chain BD Enhanced declares is sequential — each element anchored to the previous element's
87
+ ``end`` with ``offset 0`` — so resolving in element order means every ``ref_element`` is already
88
+ placed, and this reduces to a cumulative walk. Honouring the anchor rather than assuming adjacency
89
+ keeps a non-zero ``offset`` or a ``read_start``-relative element correct too.
90
+ """
91
+ a = el.anchor
92
+ if a is None:
93
+ return el.start if el.start is not None else prev_end
94
+ if a.relative_to == "element" and a.ref_element and a.ref_element in windows:
95
+ ref = windows[a.ref_element]
96
+ base = ref[0] if a.ref_side == "start" else ref[1]
97
+ return base + a.offset
98
+ if a.relative_to == "read_start":
99
+ return a.offset
100
+ # read_end, or an unresolved ref: fall back to the running position (the chain never needs this).
101
+ return prev_end + a.offset
102
+
103
+
104
+ def _resolve_for_assignment(
105
+ seq: str, read: Read, assignment: dict[str, int]
106
+ ) -> tuple[dict[str, tuple[int, int]], int, bool] | None:
107
+ """Place every element for one candidate length assignment.
108
+
109
+ Returns ``(windows, linker_mismatches, all_linkers_matched)`` or ``None`` if the layout does not
110
+ fit the read (a window runs off the 3' end). ``linker_mismatches`` is the total Hamming distance
111
+ across all ``linker``/``fixed`` elements — the smaller, the better the frame fits.
112
+ """
113
+ windows: dict[str, tuple[int, int]] = {}
114
+ pos = 0
115
+ mismatches = 0
116
+ all_matched = True
117
+ n = len(seq)
118
+ elements = read.elements
119
+ for i, el in enumerate(elements):
120
+ start = _anchor_start(el, windows, pos)
121
+ width = assignment.get(el.name)
122
+ if width is None:
123
+ width = _fixed_width(el)
124
+ if width is None:
125
+ # open-ended element (cDNA tail): runs to the end of the read; must be terminal.
126
+ if i != len(elements) - 1:
127
+ return None
128
+ end = n
129
+ else:
130
+ end = start + width
131
+ if start < 0 or end > n:
132
+ return None # this phase pushes an element off the read
133
+ windows[el.name] = (start, end)
134
+ if el.sequence is not None: # a linker/fixed element anchors the frame
135
+ tol = el.anchor.max_mismatch if el.anchor is not None else _DEFAULT_LINKER_MAX_MISMATCH
136
+ d = _hamming(seq[start:end], el.sequence)
137
+ mismatches += d
138
+ if d > tol:
139
+ all_matched = False
140
+ pos = end
141
+ return windows, mismatches, all_matched
142
+
143
+
144
+ def resolve_windows(seq: str, read: Read) -> dict[str, tuple[int, int]] | None:
145
+ """Locate each element's ``[start, end)`` in ``seq`` by phase detection; ``None`` if not found.
146
+
147
+ Picks the candidate phase whose linker/fixed elements all Hamming-match their declared sequence
148
+ (ties broken by fewest total mismatches). Requires at least one linker to anchor on — with none,
149
+ the frame is unobservable and the read is left unresolved rather than sliced arbitrarily.
150
+ """
151
+ if not any(el.sequence is not None for el in read.elements):
152
+ return None
153
+ best: tuple[int, dict[str, tuple[int, int]]] | None = None
154
+ for assignment in _candidate_assignments(read):
155
+ resolved = _resolve_for_assignment(seq, read, assignment)
156
+ if resolved is None:
157
+ continue
158
+ windows, mismatches, all_matched = resolved
159
+ if not all_matched:
160
+ continue
161
+ if best is None or mismatches < best[0]:
162
+ best = (mismatches, windows)
163
+ return best[1] if best is not None else None
164
+
165
+
166
+ def _hamming(a: str, b: str) -> int:
167
+ """Substitution distance over the compared length; length mismatch counts as all-different tail."""
168
+ d = sum(1 for x, y in zip(a, b, strict=False) if x != y) # short-zip: tail counted below
169
+ return d + abs(len(a) - len(b))
@@ -0,0 +1,147 @@
1
+ """Synthetic FASTQ generation derived PURELY from ``spec.reads`` (round-trip).
2
+
3
+ The generator never reads ``signature`` or ``backend`` — that is what makes the round-trip
4
+ (``spec -> synth -> probe -> recover; assert recovered == declared``) a real test rather than a
5
+ tautology. Barcodes with an onlist are drawn from a fixed synthetic cell pool reused across reads so
6
+ the recurrence signal (low distinct-ratio) is realistic; UMIs are fresh-random (high distinct-ratio).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import gzip
12
+ import random
13
+ from pathlib import Path
14
+
15
+ from .schema import Element, Spec
16
+
17
+ _BASES = "ACGT"
18
+
19
+
20
+ def write_fastq_gz(path: Path, seqs: list[str], *, prefix: str = "SIM") -> None:
21
+ """Write a REPRODUCIBLE ``.fastq.gz``: same reads in, same bytes out, hence the same sha256.
22
+
23
+ ``gzip.open(path, "wt")`` stamps the current mtime into the gzip header (and embeds the source
24
+ filename), so regenerating identical reads a second later produces different bytes. Everything
25
+ downstream is content-addressed by file sha256, so a wall-clock-dependent header silently
26
+ changes the dataset id — two runs over the same synthetic input never share a cache entry, and
27
+ "deterministic in (spec, seed)" quietly stops being true at the byte level where it is claimed.
28
+ ``mtime=0`` + ``filename=""`` make the output a pure function of the reads.
29
+
30
+ One writer, because this was duplicated at three sites and each carried the same latent bug.
31
+ """
32
+ payload = "".join(
33
+ f"@{prefix}:{i}\n{s}\n+\n{'I' * len(s)}\n" for i, s in enumerate(seqs)
34
+ ).encode()
35
+ with open(path, "wb") as raw, gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as gz:
36
+ gz.write(payload)
37
+
38
+
39
+ def _rand(rng: random.Random, n: int) -> str:
40
+ return "".join(rng.choice(_BASES) for _ in range(n))
41
+
42
+
43
+ def _fixed_length(el: Element) -> int | None:
44
+ """The element's constant width (pool-barcode width), or ``None`` if it has none.
45
+
46
+ A variable-length element (``min_len < max_len``) has no single width — this returns ``min_len`` as
47
+ the pool width for a barcode that happens to be variable, but the per-read *generated* length comes
48
+ from :func:`_draw_length`, which is what actually creates the diversity-insert stagger.
49
+ """
50
+ if el.start is not None and el.end is not None:
51
+ return el.end - el.start
52
+ if el.min_len is not None:
53
+ return el.min_len
54
+ return None
55
+
56
+
57
+ def _draw_length(el: Element, rng: random.Random) -> int | None:
58
+ """The length to GENERATE for one read: fixed when the element is fixed, drawn when it floats.
59
+
60
+ A ``[min_len, max_len]`` with ``min < max`` (BD Rhapsody Enhanced's 0-3 bp diversity insert) draws
61
+ a per-read length so downstream elements shift — the stagger the anchored resolver must recover.
62
+ Collapsing it to a constant (the pre-#43 behaviour) made the round-trip verify nothing about
63
+ anchoring: every synthetic read had the same offsets a fixed-window slice would have found anyway.
64
+ """
65
+ if el.start is not None and el.end is not None:
66
+ return el.end - el.start
67
+ if el.min_len is not None and el.max_len is not None and el.min_len != el.max_len:
68
+ return rng.randint(el.min_len, el.max_len)
69
+ if el.min_len is not None:
70
+ return el.min_len
71
+ return None
72
+
73
+
74
+ def build_pools(spec: Spec, *, seed: int = 0, pool_size: int = 64) -> dict[str, list[str]]:
75
+ """Build the synthetic barcode pool backing each onlist *alias*, keyed by ``Element.onlist``.
76
+
77
+ Deterministic in ``(seed, pool_size)`` and independent of read generation, so a test can
78
+ reconstruct the exact whitelist the reads will be drawn from (to register it as a synthetic
79
+ onlist for the resolver). ``pool_size`` distinct barcodes drive the low-distinct-ratio recurrence
80
+ signal; keep it well below the read count.
81
+ """
82
+ rng = random.Random(seed)
83
+ pools: dict[str, list[str]] = {}
84
+ for read in spec.reads:
85
+ for el in read.elements:
86
+ if el.onlist and el.onlist not in pools:
87
+ length = _fixed_length(el) or 16
88
+ pools[el.onlist] = [_rand(rng, length) for _ in range(pool_size)]
89
+ return pools
90
+
91
+
92
+ def generate_reads(
93
+ spec: Spec,
94
+ *,
95
+ n: int = 2000,
96
+ seed: int = 0,
97
+ pool_size: int = 64,
98
+ cdna_min: int = 60,
99
+ cdna_max: int = 91,
100
+ pools: dict[str, list[str]] | None = None,
101
+ ) -> dict[str, list[str]]:
102
+ """Generate ``n`` synthetic reads per declared read, keyed by ``Read.id``.
103
+
104
+ ``pool_size`` sets how many distinct barcodes back each onlist (drives the recurrence signal);
105
+ keep it well below ``n`` so the cell-barcode distinct-ratio lands in-band. Pass ``pools`` (from
106
+ :func:`build_pools` with the same ``seed``/``pool_size``) to reuse a known whitelist.
107
+ """
108
+ if pools is None:
109
+ pools = build_pools(spec, seed=seed, pool_size=pool_size)
110
+ rng = random.Random(seed + 1) # a stream distinct from pool construction, still deterministic
111
+ result: dict[str, list[str]] = {}
112
+ for read in spec.reads:
113
+ seqs: list[str] = []
114
+ for _ in range(n):
115
+ seqs.append(
116
+ "".join(_gen_element(el, rng, pools, cdna_min, cdna_max) for el in read.elements)
117
+ )
118
+ result[read.id] = seqs
119
+ return result
120
+
121
+
122
+ def _gen_element(
123
+ el: Element,
124
+ rng: random.Random,
125
+ pools: dict[str, list[str]],
126
+ cdna_min: int,
127
+ cdna_max: int,
128
+ ) -> str:
129
+ if el.type in ("linker", "fixed"):
130
+ return el.sequence or ""
131
+ if el.type == "diversity":
132
+ # a variable 5' insert of random bases -> the per-read stagger; length drawn in [min,max]
133
+ return _rand(rng, _draw_length(el, rng) or 0)
134
+ length = _draw_length(el, rng)
135
+ if el.type == "barcode":
136
+ return rng.choice(pools[el.onlist]) if el.onlist else _rand(rng, length or 8)
137
+ if el.type == "umi":
138
+ return _rand(rng, length or 10)
139
+ if el.type in ("cdna", "gdna"):
140
+ return _rand(rng, length or rng.randint(cdna_min, cdna_max))
141
+ if el.type == "poly_t":
142
+ return "T" * (length or 10)
143
+ if el.type == "poly_a":
144
+ return "A" * (length or 10)
145
+ if el.type == "index":
146
+ return _rand(rng, length or 8)
147
+ return _rand(rng, length or 1)