seqforge 2026.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seqforge/__init__.py +16 -0
- seqforge/cli/__init__.py +38 -0
- seqforge/cli/__main__.py +8 -0
- seqforge/cli/_common.py +105 -0
- seqforge/cli/compose.py +119 -0
- seqforge/cli/eval.py +103 -0
- seqforge/cli/harvest.py +417 -0
- seqforge/cli/hook.py +247 -0
- seqforge/cli/io.py +502 -0
- seqforge/cli/kb.py +348 -0
- seqforge/cli/manifest.py +536 -0
- seqforge/cli/probe.py +43 -0
- seqforge/cli/processing.py +192 -0
- seqforge/cli/project.py +52 -0
- seqforge/cli/resolve.py +55 -0
- seqforge/cli/root.py +66 -0
- seqforge/cli/run.py +463 -0
- seqforge/cli/schema.py +41 -0
- seqforge/compose/__init__.py +28 -0
- seqforge/compose/core.py +515 -0
- seqforge/compose/gates.py +113 -0
- seqforge/compose/params.py +447 -0
- seqforge/e2e.py +1926 -0
- seqforge/evals/__init__.py +78 -0
- seqforge/evals/case.py +382 -0
- seqforge/evals/grade.py +300 -0
- seqforge/evals/run.py +420 -0
- seqforge/harvest/__init__.py +121 -0
- seqforge/harvest/extract.py +319 -0
- seqforge/harvest/fields.py +212 -0
- seqforge/harvest/normalize.py +537 -0
- seqforge/harvest/prep.py +41 -0
- seqforge/harvest/providers.py +321 -0
- seqforge/harvest/verify.py +251 -0
- seqforge/hooks/__init__.py +33 -0
- seqforge/hooks/guards.py +214 -0
- seqforge/io/__init__.py +61 -0
- seqforge/io/archive.py +450 -0
- seqforge/io/attributes.py +190 -0
- seqforge/io/biosample/attributes.json +6341 -0
- seqforge/io/efo/labels.json +55 -0
- seqforge/io/efo.py +138 -0
- seqforge/io/onlist.py +661 -0
- seqforge/io/onlists/3M-february-2018.codes.gz +0 -0
- seqforge/io/onlists/737K-arc-v1.codes.gz +0 -0
- seqforge/io/onlists/737K-august-2016.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls1.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls2.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3-384.codes.gz +0 -0
- seqforge/io/onlists/bd-rhapsody-cls3.codes.gz +0 -0
- seqforge/io/onlists/index.json +74 -0
- seqforge/io/remote.py +659 -0
- seqforge/io/taxonomy.py +194 -0
- seqforge/kb/__init__.py +62 -0
- seqforge/kb/anchor.py +169 -0
- seqforge/kb/generate.py +147 -0
- seqforge/kb/loader.py +152 -0
- seqforge/kb/roundtrip.py +112 -0
- seqforge/kb/schema.py +422 -0
- seqforge/kb/specs/10x-3p-gex/spec.yaml +62 -0
- seqforge/kb/specs/10x-3p-gex-v2/README.md +41 -0
- seqforge/kb/specs/10x-3p-gex-v2/spec.yaml +83 -0
- seqforge/kb/specs/10x-3p-gex-v3/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3/spec.yaml +118 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/README.md +56 -0
- seqforge/kb/specs/10x-3p-gex-v3.1/spec.yaml +124 -0
- seqforge/kb/specs/bd-rhapsody-wta/README.md +103 -0
- seqforge/kb/specs/bd-rhapsody-wta/spec.yaml +130 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced/spec.yaml +99 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v1/spec.yaml +93 -0
- seqforge/kb/specs/bd-rhapsody-wta-enhanced-v2/spec.yaml +81 -0
- seqforge/kb/specs/bulk-rnaseq-pe/README.md +35 -0
- seqforge/kb/specs/bulk-rnaseq-pe/spec.yaml +97 -0
- seqforge/kb/specs/splitseq/README.md +51 -0
- seqforge/kb/specs/splitseq/spec.yaml +157 -0
- seqforge/manifest/__init__.py +61 -0
- seqforge/manifest/fill.py +531 -0
- seqforge/manifest/hash.py +77 -0
- seqforge/manifest/instruct.py +114 -0
- seqforge/manifest/policy.py +409 -0
- seqforge/manifest/validate.py +274 -0
- seqforge/models/__init__.py +268 -0
- seqforge/models/assertion.py +68 -0
- seqforge/models/base.py +100 -0
- seqforge/models/blocker.py +71 -0
- seqforge/models/conflict.py +47 -0
- seqforge/models/dataset.py +320 -0
- seqforge/models/evidenced.py +54 -0
- seqforge/models/observation.py +157 -0
- seqforge/models/processing.py +231 -0
- seqforge/models/records.py +145 -0
- seqforge/models/resolve.py +216 -0
- seqforge/probe/__init__.py +46 -0
- seqforge/probe/core.py +232 -0
- seqforge/probe/signals.py +250 -0
- seqforge/probe/streaming.py +118 -0
- seqforge/project.py +177 -0
- seqforge/py.typed +0 -0
- seqforge/resolve/__init__.py +98 -0
- seqforge/resolve/assign.py +204 -0
- seqforge/resolve/cache.py +119 -0
- seqforge/resolve/confuse.py +215 -0
- seqforge/resolve/engine.py +646 -0
- seqforge/resolve/escalate.py +668 -0
- seqforge/resolve/evaluators.py +306 -0
- seqforge/resolve/geometry.py +89 -0
- seqforge/resolve/group.py +85 -0
- seqforge/resolve/records.py +550 -0
- seqforge/resolve/scoring.py +373 -0
- seqforge/resolve/window.py +206 -0
- seqforge/workflows/__init__.py +234 -0
- seqforge/workflows/cram.py +117 -0
- seqforge/workflows/h5ad.py +368 -0
- seqforge/workflows/map/star.smk +101 -0
- seqforge/workflows/map/starsolo.smk +360 -0
- seqforge/workflows/qc.py +157 -0
- seqforge/workspace.py +125 -0
- seqforge-2026.7.1.dist-info/METADATA +125 -0
- seqforge-2026.7.1.dist-info/RECORD +124 -0
- seqforge-2026.7.1.dist-info/WHEEL +4 -0
- seqforge-2026.7.1.dist-info/entry_points.txt +2 -0
- seqforge-2026.7.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|