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,668 @@
1
+ """Escalation: ranked evaluations -> ``{Decision | Conflict | Question | Blocker}`` (§3.5).
2
+
3
+ Deterministic code owns the decision; the hypothesis only changes *which* candidates are computed and
4
+ can break a genuinely-non-decisive divergent tie (recorded ``basis: asserted``, surfaced). The three
5
+ terminal shapes:
6
+
7
+ - **Decision** — a clear winner (``margin > θ``, no divergent tie). Declared ``processing_equivalent``
8
+ twins are recorded together into the chemistry equivalence class with **0** questions (§12 benign).
9
+ - **Conflict** — an observed value contradicts an asserted one. Detected unconditionally, in parallel;
10
+ the library always takes the observed value. A CROSS-family contradiction (single-cell asserted, bulk
11
+ observed) is surfaced ``open`` — exit 4, a human decides. A WITHIN-family geometry difference
12
+ (asserted v2 26 bp, observed v3 28 bp) is recorded ``resolved`` — the bytes decide the leaf and the
13
+ paper's family-level claim still holds — so it is auditable but does not block (exit 0).
14
+ - **Question / Blocker** — a processing-*divergent* tie that metadata/onlist can't settle routes to a
15
+ human (exit 4); a structural dead end (missing technical read, truncated gzip, unsupported tech)
16
+ is a ``Blocker`` (exit 3).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+
23
+ from ..kb.schema import Read, SegmentLength, Spec
24
+ from ..models.blocker import Blocker, BlockerCode, BlockerSubject
25
+ from ..models.conflict import Conflict, ConflictPosition, Resolution
26
+ from ..models.observation import Observation
27
+ from ..models.resolve import Candidate, Question, RoleAssignment
28
+ from .confuse import is_processing_equivalent, same_family, sibling_decided_by
29
+ from .scoring import TechEvaluation
30
+
31
+ _THETA = 0.02 # tie threshold: candidates within θ of the top are a "tie set"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Escalation:
36
+ """The escalation verdict: ranked candidates plus any conflicts / questions / blockers."""
37
+
38
+ candidates: list[Candidate]
39
+ conflicts: list[Conflict] = field(default_factory=list)
40
+ questions: list[Question] = field(default_factory=list)
41
+ blockers: list[Blocker] = field(default_factory=list)
42
+ rung_reached: int = 0
43
+ winner: str | None = None
44
+
45
+
46
+ def escalate(
47
+ evaluations: list[TechEvaluation],
48
+ observations: list[Observation],
49
+ specs: dict[str, Spec],
50
+ hypothesis_value: str | None,
51
+ hypothesis_id: str | None,
52
+ hypothesis_confidence: float,
53
+ ) -> Escalation:
54
+ """Turn scored technologies into a single terminal verdict."""
55
+ integrity = _integrity_blockers(observations)
56
+ if integrity:
57
+ return Escalation(candidates=[], blockers=integrity, rung_reached=2)
58
+
59
+ # `tech` is the LAST key and it is here for determinism, not for judgement: two candidates can tie
60
+ # on (value, rung) exactly — §12 benign twins do it BY CONSTRUCTION, since they are byte-identical
61
+ # — and without a final tiebreak the ordering falls through to the KB dict's iteration order. The
62
+ # representative of an equivalence class is arbitrary; it still has to be arbitrary the SAME way on
63
+ # every run, or `candidates[0].technology` flips between runs of an unchanged input.
64
+ valid = sorted((e for e in evaluations if e.valid), key=lambda e: (-e.value, -e.rung, e.tech))
65
+ if not valid:
66
+ blocker = _no_candidate_blocker(evaluations, hypothesis_value, specs)
67
+ return Escalation(candidates=[], blockers=[blocker], rung_reached=2)
68
+
69
+ # Within the score tie (candidates within θ of the best), the STRONGER evidence TIER wins: rung 3
70
+ # (an onlist was consulted) outranks rung-2 geometry, so a lower-rung look-alike is DOMINATED, not a
71
+ # divergent-tie question. That is how onlist evidence separates a specific chemistry from the generic
72
+ # bulk fallback that merely failed to be forbidden — but only WITHIN θ. On OVER-LENGTH reads a 75 bp
73
+ # barcode read is also a fine cDNA, so bulk edges the real chemistry out by more than θ and, without
74
+ # help, a single-cell library whose whitelist *hit* (#7) collapses to bulk at exit 0. So anchor the
75
+ # tie on the barcoded candidate whose onlist positively matched, letting its decisive rung-3
76
+ # evidence win. The gate is ``barcode_onlist_hit`` (the whitelist ACTUALLY matched), NOT rung 3 (an
77
+ # onlist was merely consulted): a random ~100 bp bulk read passes a barcode read's over-length
78
+ # geometry gate and reaches rung 3 with a FAILING onlist, and must stay bulk. Canonical single-cell
79
+ # already out-scores bulk (a short barcode read is a poor cDNA), so there the anchor is a no-op.
80
+ anchor = valid[0]
81
+ if _barcode_read_id(specs[anchor.tech]) is None:
82
+ anchor = next(
83
+ (
84
+ e
85
+ for e in valid
86
+ if e.barcode_onlist_hit and _barcode_read_id(specs[e.tech]) is not None
87
+ ),
88
+ anchor,
89
+ )
90
+ best_value = anchor.value
91
+ tie = [e for e in valid if best_value - e.value <= _THETA]
92
+ top = sorted(tie, key=lambda e: (-e.rung, -e.value, e.tech))[0]
93
+ top_spec = specs[top.tech]
94
+ rung = max(e.rung for e in tie)
95
+
96
+ contenders = [e for e in tie if e.tech != top.tech and e.rung >= top.rung]
97
+ equivalent_ties = [
98
+ e
99
+ for e in contenders
100
+ if is_processing_equivalent(top_spec, e.tech)
101
+ or is_processing_equivalent(specs[e.tech], top.tech)
102
+ ]
103
+ divergent_ties = [e for e in contenders if e not in equivalent_ties]
104
+
105
+ conflicts = _detect_conflicts(
106
+ hypothesis_value,
107
+ hypothesis_id,
108
+ hypothesis_confidence,
109
+ top,
110
+ top_spec,
111
+ observations,
112
+ specs,
113
+ rung,
114
+ )
115
+ collapse = _single_cell_collapse_conflict(
116
+ hypothesis_value, hypothesis_id, hypothesis_confidence, top, top_spec, observations, specs
117
+ )
118
+ if collapse is not None:
119
+ conflicts.append(collapse)
120
+ reverse = _bulk_asserted_single_cell_observed(
121
+ hypothesis_value, hypothesis_id, hypothesis_confidence, top, top_spec, observations, specs
122
+ )
123
+ if reverse is not None:
124
+ conflicts.append(reverse)
125
+ # Pre-trimming can only be judged once a role is known — it is variable length *on a read the
126
+ # chemistry says is fixed*, so it needs the winner's assignment, not raw bytes. Hence here and
127
+ # not in `_integrity_blockers`.
128
+ trimmed = _pretrimmed_blockers(top, top_spec, observations)
129
+ if trimmed:
130
+ return Escalation(candidates=[], blockers=trimmed, conflicts=conflicts, rung_reached=rung)
131
+ barcode_absent = _barcodeless_seated_blocker(top, top_spec, valid)
132
+ if barcode_absent is not None:
133
+ return Escalation(
134
+ candidates=[], blockers=[barcode_absent], conflicts=conflicts, rung_reached=rung
135
+ )
136
+ equiv_members = sorted(set(top.equivalence_members) | {e.tech for e in equivalent_ties})
137
+
138
+ if not divergent_ties:
139
+ candidates = [_candidate(top, equiv_members, rung)]
140
+ candidates += [_candidate(e, e.equivalence_members, rung) for e in valid if e is not top]
141
+ return Escalation(
142
+ candidates=candidates, conflicts=conflicts, rung_reached=rung, winner=top.tech
143
+ )
144
+
145
+ # a processing-divergent tie: metadata (rung 0) may still disambiguate; else a human question.
146
+ picked = _metadata_disambiguation(hypothesis_value, top, divergent_ties, specs)
147
+ if picked is not None:
148
+ candidates = [_candidate(picked, picked.equivalence_members, rung)]
149
+ candidates += [_candidate(e, e.equivalence_members, rung) for e in valid if e is not picked]
150
+ return Escalation(
151
+ candidates=candidates,
152
+ conflicts=conflicts,
153
+ rung_reached=max(rung, 0),
154
+ winner=picked.tech,
155
+ )
156
+
157
+ question = _divergent_question(top, divergent_ties, specs)
158
+ candidates = [_candidate(e, e.equivalence_members, rung) for e in valid]
159
+ return Escalation(
160
+ candidates=candidates,
161
+ conflicts=conflicts,
162
+ questions=[question],
163
+ rung_reached=7,
164
+ winner=None,
165
+ )
166
+
167
+
168
+ def _declared_fixed_length(spec: Spec, read: Read) -> tuple[int, int | None] | None:
169
+ """A read's declared fixed length and its over-length escape, or ``None`` if it is not fixed-cycle.
170
+
171
+ Fixed either by ``min_len == max_len`` (a bare geometry) OR by a ``segment_length`` requires —
172
+ which is how an over-length-capable read (a 10x R1) declares its canonical length while ``max_len``
173
+ stays null. Returning the ``over_length_min`` lets the caller exempt a genuinely over-length read.
174
+ """
175
+ if read.min_len is not None and read.min_len == read.max_len:
176
+ return read.min_len, None
177
+ for t in spec.signature.requires:
178
+ if isinstance(t, SegmentLength) and t.read == read.id:
179
+ return t.length, t.over_length_min
180
+ return None
181
+
182
+
183
+ def _pretrimmed_blockers(
184
+ top: TechEvaluation, spec: Spec, observations: list[Observation]
185
+ ) -> list[Blocker]:
186
+ """Variable length on a read the chemistry declares FIXED => someone trimmed before uploading.
187
+
188
+ This is the quiet failure §5 is built around, and it survives every other check by construction.
189
+ ``read_length_compatible`` matches on the **mode**, so a file whose reads are mostly 28 bp with a
190
+ trimmed tail scores exactly like a clean one and wins its candidate outright. Nothing downstream
191
+ looks again: STARsolo reads the barcode from a fixed offset, and on a shifted read that offset is
192
+ an arbitrary 16-mer. It matches no whitelist, the cell is dropped, the matrix comes out thin, and
193
+ STAR exits 0.
194
+
195
+ A fixed-cycle Illumina run does not produce variable-length reads. If the technical read is
196
+ variable, a trimmer ran — and cutadapt/trimmomatic do not know a barcode from an adapter.
197
+
198
+ An OVER-LENGTH read (a barcode read sequenced past CB+UMI) is exempt: its length varies only in
199
+ the junk tail, while CB/UMI stay at their fixed offsets, so that variation is not a trimmed
200
+ barcode. The canonical length is still enforced — a read at its declared length that is *also*
201
+ variable is trimmed and blocks, exactly as before.
202
+ """
203
+ by_sha = {o.file.sha256: o for o in observations}
204
+ assigned = top.role_assignment_shas()
205
+ blockers: list[Blocker] = []
206
+ for read in spec.reads:
207
+ fixed = _declared_fixed_length(spec, read)
208
+ if fixed is None:
209
+ continue
210
+ declared, over_min = fixed
211
+ sha = assigned.get(read.id)
212
+ obs = by_sha.get(sha) if sha else None
213
+ if obs is None or obs.read_length.n_distinct == 1:
214
+ continue
215
+ if over_min is not None and obs.read_length.mode >= over_min:
216
+ continue # over-length: variation is in the junk tail, not the barcode
217
+ role_id = read.id
218
+ ref = obs.file.basename
219
+ blockers.append(
220
+ Blocker(
221
+ id=f"blk-pretrimmed-{obs.file.sha256[:8]}",
222
+ code=BlockerCode.PRETRIMMED_VARIABLE_LENGTH,
223
+ message=(
224
+ f"{ref}: {spec.identity.id} declares read {role_id!r} as fixed-cycle "
225
+ f"({declared} bp), but the file carries {obs.read_length.n_distinct} distinct "
226
+ f"read lengths (mode {obs.read_length.mode}). A trimmer ran before upload, so "
227
+ f"barcode/UMI offsets may have shifted — counts would be silently wrong."
228
+ ),
229
+ remedy=(
230
+ "Re-fetch the untrimmed original (SRA's sra-pub-src-* buckets preserve the "
231
+ "submitter's files), or confirm the technical read was excluded from trimming "
232
+ "and re-probe."
233
+ ),
234
+ subject=BlockerSubject(kind="file", ref=ref),
235
+ evidence=[obs.file.sha256],
236
+ )
237
+ )
238
+ return blockers
239
+
240
+
241
+ def _barcodeless_seated_blocker(
242
+ top: TechEvaluation, top_spec: Spec, valid: list[TechEvaluation]
243
+ ) -> Blocker | None:
244
+ """F1b — the winning chemistry is barcoded, its barcode role is FILLED, and NO byte-consistent
245
+ barcoded candidate hits a whitelist though one WAS available to check (``barcode_onlist_available``).
246
+ STARsolo would read barcodes from a read matching nothing and report ~0 valid barcodes at exit 0 — a
247
+ silently empty matrix. Refuse instead.
248
+
249
+ The gate is over ALL valid candidates, not just ``top``: if any barcoded leaf's whitelist positively
250
+ matched, the data IS barcoded and the winner resolves to that leaf, so this must abstain. The case
251
+ that forces it is the over-length v2/v3 tie — a 150 bp 10x v3 library where v2 edges v3 on raw score
252
+ (so ``top`` is v2, whose 737K list misses) while v3's 3M list hits; blocking on ``top`` alone would
253
+ refuse a perfectly good v3 dataset before the tie/hypothesis picks v3. Only a dataset where no
254
+ barcoded chemistry matched at all is genuinely barcode-absent.
255
+
256
+ Fires only where the whitelist is the arbiter: a bulk winner (no barcode role) is the
257
+ ``_single_cell_collapse_conflict`` guard's job, and a chemistry whose whitelist was never consulted
258
+ is not onlist-judgeable (abstain). Distinct from ``MISSING_TECHNICAL_READ``, where the barcode role
259
+ is structurally UNFILLABLE — here the role is filled, the seated read just is not barcoded.
260
+ """
261
+ if _barcode_read_id(top_spec) is None:
262
+ return None # a bulk winner has no barcode role — the collapse guard's job, not this
263
+ if any(e.barcode_onlist_hit for e in valid):
264
+ return None # some byte-consistent barcoded leaf DID hit — the data is barcoded, not absent
265
+ if not top.barcode_onlist_available:
266
+ return None # no whitelist was consulted: absence is not decidable, defer to the geometry gates
267
+ return Blocker(
268
+ id=f"blk-barcode-absent-{top.tech}",
269
+ code=BlockerCode.BARCODE_READ_ABSENT,
270
+ message=(
271
+ f"{top.tech} is barcoded, but no read carries whitelist-matchable barcodes: the seated "
272
+ "barcode read matches the chemistry's whitelist only at chance. STARsolo would report "
273
+ "near-zero valid barcodes and exit 0 with an empty matrix."
274
+ ),
275
+ remedy=(
276
+ "Confirm the barcode/technical read was included — SRA drops it unless dumped with "
277
+ "`fasterq-dump --include-technical`; re-fetch the original submitted files "
278
+ "(`sra-pub-src-*` via the SDL API) if it was stripped, then re-probe."
279
+ ),
280
+ subject=BlockerSubject(kind="dataset", ref=top.tech),
281
+ )
282
+
283
+
284
+ def _integrity_blockers(observations: list[Observation]) -> list[Blocker]:
285
+ blockers: list[Blocker] = []
286
+ for obs in observations:
287
+ ref = obs.file.basename
288
+ if obs.gzip.truncated:
289
+ blockers.append(
290
+ Blocker(
291
+ id=f"blk-truncated-{obs.file.sha256[:8]}",
292
+ code=BlockerCode.TRUNCATED_GZIP,
293
+ message=f"{ref}: gzip stream ends mid-record (truncated upload/transfer).",
294
+ remedy="Re-download the file and verify its checksum before re-probing.",
295
+ subject=BlockerSubject(kind="file", ref=ref),
296
+ evidence=[obs.file.sha256],
297
+ )
298
+ )
299
+ elif not obs.gzip.ok:
300
+ blockers.append(
301
+ Blocker(
302
+ id=f"blk-corrupt-{obs.file.sha256[:8]}",
303
+ code=BlockerCode.CORRUPT_FASTQ,
304
+ message=f"{ref}: not a readable gzip FASTQ.",
305
+ remedy="Re-download the file; confirm it is gzip-compressed FASTQ.",
306
+ subject=BlockerSubject(kind="file", ref=ref),
307
+ evidence=[obs.file.sha256],
308
+ )
309
+ )
310
+ return blockers
311
+
312
+
313
+ def _no_candidate_blocker(
314
+ evaluations: list[TechEvaluation], hypothesis_value: str | None, specs: dict[str, Spec]
315
+ ) -> Blocker:
316
+ """No technology passed its requires: a missing technical read, or genuinely unsupported."""
317
+ hyp_tech = _match_tech(hypothesis_value, specs) if hypothesis_value else None
318
+ if hyp_tech is not None:
319
+ e = next((ev for ev in evaluations if ev.tech == hyp_tech), None)
320
+ if (
321
+ e is not None
322
+ and e.barcode_role_ids
323
+ and set(e.unfillable_role_ids) & set(e.barcode_role_ids)
324
+ and e.cdna_role_fillable
325
+ ):
326
+ return Blocker(
327
+ id=f"blk-missing-technical-{hyp_tech}",
328
+ code=BlockerCode.MISSING_TECHNICAL_READ,
329
+ message=(
330
+ f"Metadata asserts {hyp_tech} (single-cell), but the technical/barcode read is "
331
+ "absent — only a cDNA-shaped read is present."
332
+ ),
333
+ remedy=(
334
+ "Re-fetch with `fasterq-dump --include-technical`, or pull the original submitted "
335
+ "files `sra-pub-src-*` via the SRA Data Locator / SDL API."
336
+ ),
337
+ subject=BlockerSubject(kind="dataset", ref=hyp_tech),
338
+ )
339
+ return Blocker(
340
+ id="blk-unsupported",
341
+ code=BlockerCode.UNSUPPORTED_TECHNOLOGY,
342
+ message="No knowledge-base technology matches these reads' structure.",
343
+ remedy="Add a KB entry for this technology, or verify the inputs are the expected FASTQs.",
344
+ subject=BlockerSubject(kind="dataset", ref="dataset"),
345
+ )
346
+
347
+
348
+ def _detect_conflicts(
349
+ hypothesis_value: str | None,
350
+ hypothesis_id: str | None,
351
+ hypothesis_confidence: float,
352
+ top: TechEvaluation,
353
+ top_spec: Spec,
354
+ observations: list[Observation],
355
+ specs: dict[str, Spec],
356
+ rung: int,
357
+ ) -> list[Conflict]:
358
+ """Surface an observed-vs-asserted geometry contradiction (e.g. asserted v2 26 bp, observed 28 bp).
359
+
360
+ A WITHIN-FAMILY difference (asserted v2, observed v3 — both ``10x-3p-gex`` leaves) is NOT a blocking
361
+ conflict: a paper names the assay *family* reliably and the exact *leaf* vaguely, and the bytes
362
+ decide the leaf (whitelist + UMI length). So it is recorded as a ``resolved`` conflict — the
363
+ discarded claim survives for audit ("three truths, never merged"), but it does not block. A
364
+ CROSS-FAMILY length difference stays an ``open`` conflict (exit 4, a human decides). This is the
365
+ GSE229022 lesson: "10x 3' v2/v3" in prose, byte-provably v3, is agreement at the family level.
366
+ """
367
+ if not hypothesis_value:
368
+ return []
369
+ asserted_len = _asserted_barcode_length(hypothesis_value, specs)
370
+ observed_len = _observed_barcode_length(top, top_spec, observations)
371
+ if asserted_len is None or observed_len is None or asserted_len == observed_len:
372
+ return []
373
+ over_min = _spec_over_length_min(top_spec)
374
+ if over_min is not None and observed_len >= over_min:
375
+ # An over-length barcode read is EXPECTED for this chemistry (CB/UMI at fixed offsets, the rest
376
+ # junk), not a geometry contradiction — so 28-vs-150 is agreement, not a conflict to surface.
377
+ return []
378
+ positions = [
379
+ ConflictPosition(
380
+ value=str(asserted_len),
381
+ basis="asserted",
382
+ evidence=[hypothesis_id] if hypothesis_id else [],
383
+ confidence=hypothesis_confidence,
384
+ ),
385
+ ConflictPosition(
386
+ value=str(observed_len),
387
+ basis="observed",
388
+ evidence=[o.file.sha256 for o in observations],
389
+ confidence=0.99,
390
+ ),
391
+ ]
392
+ asserted_tech = _match_tech(hypothesis_value, specs)
393
+ if asserted_tech is not None and same_family(specs, asserted_tech, top.tech):
394
+ # Within-family leaf difference: harvest named the family (reliable), the bytes decide the leaf
395
+ # (v2 vs v3). Resolve to the observed leaf; keep the asserted claim as a RESOLVED conflict so the
396
+ # disagreement is auditable but does not block (exit 0).
397
+ return [
398
+ Conflict(
399
+ id="conflict-barcode-length",
400
+ field="library.read_layout.R1.length",
401
+ kind="observed_vs_asserted",
402
+ positions=positions,
403
+ decidable_by=["reads"],
404
+ status="resolved",
405
+ resolution=Resolution(
406
+ chosen_value=str(observed_len),
407
+ basis="observed",
408
+ rung=rung,
409
+ decided_by="code",
410
+ note=(
411
+ f"within-family leaf difference; the bytes decide the leaf ({top.tech}) — "
412
+ "the paper's family-level claim is satisfied"
413
+ ),
414
+ ),
415
+ )
416
+ ]
417
+ return [
418
+ Conflict(
419
+ id="conflict-barcode-length",
420
+ field="library.read_layout.R1.length",
421
+ kind="observed_vs_asserted",
422
+ positions=positions,
423
+ decidable_by=["reads"],
424
+ status="open",
425
+ )
426
+ ]
427
+
428
+
429
+ def _single_cell_collapse_conflict(
430
+ hypothesis_value: str | None,
431
+ hypothesis_id: str | None,
432
+ hypothesis_confidence: float,
433
+ top: TechEvaluation,
434
+ top_spec: Spec,
435
+ observations: list[Observation],
436
+ specs: dict[str, Spec],
437
+ ) -> Conflict | None:
438
+ """A single-cell chemistry was asserted, but the winning byte candidate is a **barcodeless bulk**
439
+ library. Surface it (#7/#11) rather than let it collapse silently.
440
+
441
+ The failure this catches: the asserted single-cell tech's barcode read was *forbidden* — trimmed,
442
+ or over-sequenced past its length gate — so that tech dropped out of ``valid`` and the generic bulk
443
+ fallback won by default. The result is a bulk manifest for a single-cell dataset, at exit 0. That
444
+ is the quiet corpus-poisoning §5 exists to prevent (GSE126954's over-length SRX5411291; GSE274290
445
+ before a BD Rhapsody spec exists).
446
+
447
+ ``_detect_conflicts`` provably cannot see this: it compares barcode *lengths*, and a bulk winner
448
+ has no barcode read, so ``_observed_barcode_length`` is ``None`` and that guard returns early. This
449
+ one keys on structure — asserted-barcoded vs observed-barcodeless — not on a length delta. Like the
450
+ length conflict it only surfaces (open Conflict, exit 4); it never arbitrates, because whether the
451
+ data *is* single-cell or bulk is exactly the call code may not auto-pick.
452
+ """
453
+ if not hypothesis_value:
454
+ return None
455
+ hyp_tech = _match_tech(hypothesis_value, specs)
456
+ if hyp_tech is None:
457
+ return None # the asserted chemistry names no KB tech, so "single-cell" is not established
458
+ if _barcode_read_id(specs[hyp_tech]) is None:
459
+ return None # a bulk chemistry was asserted and bulk won — no collapse, agreement
460
+ if _barcode_read_id(top_spec) is not None:
461
+ return None # the winner is itself barcoded (single-cell won or tied) — nothing collapsed
462
+ return Conflict(
463
+ id="conflict-single-cell-collapsed-to-bulk",
464
+ field="library.chemistry",
465
+ kind="observed_vs_asserted",
466
+ positions=[
467
+ ConflictPosition(
468
+ value=hyp_tech,
469
+ basis="asserted",
470
+ evidence=[hypothesis_id] if hypothesis_id else [],
471
+ confidence=hypothesis_confidence,
472
+ ),
473
+ ConflictPosition(
474
+ value=top.tech,
475
+ basis="observed",
476
+ evidence=[o.file.sha256 for o in observations],
477
+ confidence=0.99,
478
+ ),
479
+ ],
480
+ decidable_by=["reads"],
481
+ status="open",
482
+ )
483
+
484
+
485
+ def _bulk_asserted_single_cell_observed(
486
+ hypothesis_value: str | None,
487
+ hypothesis_id: str | None,
488
+ hypothesis_confidence: float,
489
+ top: TechEvaluation,
490
+ top_spec: Spec,
491
+ observations: list[Observation],
492
+ specs: dict[str, Spec],
493
+ ) -> Conflict | None:
494
+ """The mirror image of ``_single_cell_collapse_conflict``: a **bulk** chemistry was asserted, but the
495
+ winning byte candidate is a **barcoded single-cell** library. Surface it (exit 4) rather than emit a
496
+ single-cell manifest for a dataset the paper calls bulk.
497
+
498
+ Same error class as the collapse, the other direction — a wrong data-vs-paper pairing or a mis-written
499
+ methods section, and equally not something to let go. It is cross-family (bulk vs a single-cell
500
+ family), so ``same_family`` never suppresses it. And ``_detect_conflicts`` cannot see it: an asserted
501
+ *bulk* chemistry has no barcode read, so ``_asserted_barcode_length`` is ``None`` and that guard
502
+ returns early — which is why this is a separate structural check. Like the collapse it only surfaces;
503
+ it never arbitrates.
504
+ """
505
+ if not hypothesis_value:
506
+ return None
507
+ hyp_tech = _match_tech(hypothesis_value, specs)
508
+ if hyp_tech is None:
509
+ return None # the asserted chemistry names no KB tech, so "bulk" is not established
510
+ if _barcode_read_id(specs[hyp_tech]) is not None:
511
+ return None # a single-cell chemistry was asserted — the forward collapse guard's job, not this
512
+ if _barcode_read_id(top_spec) is None:
513
+ return None # the winner is itself bulk (agreement) — nothing to surface
514
+ return Conflict(
515
+ id="conflict-bulk-asserted-single-cell-observed",
516
+ field="library.chemistry",
517
+ kind="observed_vs_asserted",
518
+ positions=[
519
+ ConflictPosition(
520
+ value=hyp_tech,
521
+ basis="asserted",
522
+ evidence=[hypothesis_id] if hypothesis_id else [],
523
+ confidence=hypothesis_confidence,
524
+ ),
525
+ ConflictPosition(
526
+ value=top.tech,
527
+ basis="observed",
528
+ evidence=[o.file.sha256 for o in observations],
529
+ confidence=0.99,
530
+ ),
531
+ ],
532
+ decidable_by=["reads"],
533
+ status="open",
534
+ )
535
+
536
+
537
+ def _metadata_disambiguation(
538
+ hypothesis_value: str | None,
539
+ top: TechEvaluation,
540
+ divergent_ties: list[TechEvaluation],
541
+ specs: dict[str, Spec],
542
+ ) -> TechEvaluation | None:
543
+ """If a span-verified hypothesis names one tie member, pick it (rung 0, surfaced ``asserted``)."""
544
+ if not hypothesis_value:
545
+ return None
546
+ hyp_tech = _match_tech(hypothesis_value, specs)
547
+ if hyp_tech is None:
548
+ return None
549
+ for e in [top, *divergent_ties]:
550
+ if e.tech == hyp_tech:
551
+ return e
552
+ return None
553
+
554
+
555
+ def _divergent_question(
556
+ top: TechEvaluation, divergent_ties: list[TechEvaluation], specs: dict[str, Spec]
557
+ ) -> Question:
558
+ options = sorted({top.tech, *(e.tech for e in divergent_ties)})
559
+ decidable: set[str] = set()
560
+ for c in specs[top.tech].confusable_with:
561
+ if c.id in options and c.relationship == "processing_divergent":
562
+ decidable.update(c.distinguishable_by)
563
+ # Siblings no longer carry a per-pair edge: their separating mechanism lives in the shared parent's
564
+ # `children_decided_by`, sourced here so a v2-vs-v3 over-length tie is still `decidable_by: onlist`.
565
+ for opt in options:
566
+ if opt != top.tech:
567
+ decidable.update(sibling_decided_by(specs, top.tech, opt))
568
+ decidable.discard("none")
569
+ return Question(
570
+ id="q-chemistry",
571
+ field="library.chemistry",
572
+ prompt=(
573
+ "Reads are byte-consistent with multiple processing-divergent chemistries "
574
+ f"({', '.join(options)}) that onlist/metadata could not separate. Which chemistry applies?"
575
+ ),
576
+ options=options,
577
+ decidable_by=sorted(decidable) or ["user"], # type: ignore[arg-type]
578
+ rung=7,
579
+ )
580
+
581
+
582
+ def _candidate(e: TechEvaluation, equiv_members: list[str], rung: int) -> Candidate:
583
+ return Candidate(
584
+ technology=e.tech,
585
+ score=e.score,
586
+ role_assignment=RoleAssignment(
587
+ assignment=e.role_assignment_shas(),
588
+ unassigned=[e.file_shas[f] for f in e.assignment.unassigned_files],
589
+ ),
590
+ rung_resolved={"chemistry": rung},
591
+ equivalence_members=equiv_members,
592
+ evidence=[],
593
+ )
594
+
595
+
596
+ # ---- geometry helpers ----
597
+ def _barcode_read_id(spec: Spec) -> str | None:
598
+ for read in spec.reads:
599
+ if any(el.type == "barcode" for el in read.elements):
600
+ return read.id
601
+ return None
602
+
603
+
604
+ def _spec_barcode_length(spec: Spec) -> int | None:
605
+ """The declared barcode-read length: a ``segment_length`` requires, else a fixed ``min_len``."""
606
+ bc = _barcode_read_id(spec)
607
+ if bc is None:
608
+ return None
609
+ for t in spec.signature.requires:
610
+ if isinstance(t, SegmentLength) and t.read == bc:
611
+ return t.length
612
+ for read in spec.reads:
613
+ if read.id == bc and read.min_len is not None and read.min_len == read.max_len:
614
+ return read.min_len
615
+ return None
616
+
617
+
618
+ def _spec_over_length_min(spec: Spec) -> int | None:
619
+ """The barcode read's over-length escape, if it declares one (a mode >= this is expected)."""
620
+ bc = _barcode_read_id(spec)
621
+ if bc is None:
622
+ return None
623
+ for t in spec.signature.requires:
624
+ if isinstance(t, SegmentLength) and t.read == bc:
625
+ return t.over_length_min
626
+ return None
627
+
628
+
629
+ def _asserted_barcode_length(value: str, specs: dict[str, Spec]) -> int | None:
630
+ stripped = value.strip()
631
+ if stripped.isdigit():
632
+ return int(stripped)
633
+ tech = _match_tech(value, specs)
634
+ return _spec_barcode_length(specs[tech]) if tech else None
635
+
636
+
637
+ def _observed_barcode_length(
638
+ top: TechEvaluation, top_spec: Spec, observations: list[Observation]
639
+ ) -> int | None:
640
+ bc = _barcode_read_id(top_spec)
641
+ if bc is None:
642
+ return None
643
+ sha = top.role_assignment_shas().get(bc)
644
+ if sha is None:
645
+ return None
646
+ for obs in observations:
647
+ if obs.file.sha256 == sha:
648
+ return obs.read_length.mode
649
+ return None
650
+
651
+
652
+ def _match_tech(value: str | None, specs: dict[str, Spec]) -> str | None:
653
+ """Match an asserted chemistry string to a KB tech id or alias (case-insensitive)."""
654
+ if not value:
655
+ return None
656
+ needle = value.strip().lower()
657
+ for tech_id in specs:
658
+ if needle == tech_id.lower():
659
+ return tech_id
660
+ for tech_id, spec in specs.items():
661
+ names = [spec.identity.id, spec.identity.name, *spec.identity.aliases]
662
+ if any(needle == n.lower() for n in names):
663
+ return tech_id
664
+ for tech_id, spec in specs.items():
665
+ names = [spec.identity.name, *spec.identity.aliases]
666
+ if any(needle in n.lower() or n.lower() in needle for n in names):
667
+ return tech_id
668
+ return None