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,204 @@
|
|
|
1
|
+
"""Joint role->file assignment: the injective, cardinality-normalized optimization (§3.3).
|
|
2
|
+
|
|
3
|
+
An assignment ``A: R_t -> F`` is **injective** (each role a distinct file). ``valid(A)`` selects no
|
|
4
|
+
forbidden cell and fills every role. Over valid ``A`` we optimize ``(coverage, Σ(cell + β·prior))``
|
|
5
|
+
lexicographically — coverage first (see below), then score. The common single-cell case (``|F| <= 4``)
|
|
6
|
+
is solved by brute force over all injective maps; larger ``|F|`` uses an O(n^3) Hungarian on
|
|
7
|
+
``-(bonus + cell + β·prior)`` (forbidden as a large finite cost; the coverage bonus is dropped from the
|
|
8
|
+
reported score) with a post-check that no selected edge is a forbidden edge (an all-forbidden role =>
|
|
9
|
+
unfillable => not a padded win).
|
|
10
|
+
|
|
11
|
+
**Coverage precedes score.** A file eligible for exactly one role (forbidden for every other) can be
|
|
12
|
+
placed nowhere else, so an assignment that leaves it orphaned while a multi-role file takes its role
|
|
13
|
+
is coverage-wrong even when it scores higher. We therefore optimize ``(coverage, Σ(cell + β·prior))``
|
|
14
|
+
lexicographically: first the count of single-role-eligible files that claim their sole role, then the
|
|
15
|
+
score. This is a no-op wherever injectivity already forces the map — the common one-file-per-role case
|
|
16
|
+
— and only bites a multi-file-per-role run (many lanes/flowcells under one accession), where a short
|
|
17
|
+
barcode read may out-score the real long cDNA read for the cDNA role yet the cDNA-length reads are the
|
|
18
|
+
only files that role can take. The coverage term orders the search; it is excluded from the reported
|
|
19
|
+
score, which stays the honest ``Σ(cell + β·prior)`` of the chosen map.
|
|
20
|
+
|
|
21
|
+
The filename prior is a *sub-threshold* nudge (``β << min(weight)``): it can only break an exact
|
|
22
|
+
byte-tie, never override a gate or flip validity.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from itertools import permutations
|
|
29
|
+
from math import factorial, inf
|
|
30
|
+
|
|
31
|
+
#: Brute-force whenever the number of injective maps P(F, R) is at most this; else Hungarian.
|
|
32
|
+
_BRUTE_CAP = 40_320 # 8!
|
|
33
|
+
_BIG = 1e9 # a finite forbidden-edge cost for the Hungarian path (never selected if avoidable)
|
|
34
|
+
#: Per-cell selection bonus for a single-role-eligible file claiming its sole role. Large enough that
|
|
35
|
+
#: one more such placement outranks any score/prior configuration, small enough to sit under ``_BIG``;
|
|
36
|
+
#: excluded from the reported score, so it orders coverage above score and nothing more.
|
|
37
|
+
_COVERAGE_BONUS = 1e3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class AssignmentResult:
|
|
42
|
+
"""The best valid injective assignment for one technology (or an invalid verdict + reason)."""
|
|
43
|
+
|
|
44
|
+
valid: bool
|
|
45
|
+
#: role index -> file index (only for a valid assignment)
|
|
46
|
+
mapping: dict[int, int] = field(default_factory=dict)
|
|
47
|
+
#: file indices left over (index reads / ignored), penalized at rate lambda
|
|
48
|
+
unassigned_files: list[int] = field(default_factory=list)
|
|
49
|
+
raw: float = -inf
|
|
50
|
+
#: roles forbidden on EVERY file (structurally unfillable) — drives MISSING_TECHNICAL_READ
|
|
51
|
+
unfillable_roles: list[int] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _n_injective(n_files: int, n_roles: int) -> int:
|
|
55
|
+
if n_files < n_roles:
|
|
56
|
+
return 0
|
|
57
|
+
return factorial(n_files) // factorial(n_files - n_roles)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def best_assignment(
|
|
61
|
+
n_roles: int,
|
|
62
|
+
n_files: int,
|
|
63
|
+
score: list[list[float]],
|
|
64
|
+
forbidden: list[list[bool]],
|
|
65
|
+
prior: list[list[float]],
|
|
66
|
+
) -> AssignmentResult:
|
|
67
|
+
"""Return the best valid injective role->file assignment — coverage first, then score (or invalid).
|
|
68
|
+
|
|
69
|
+
``score``/``forbidden``/``prior`` are ``n_roles x n_files``. ``score`` is the finite support
|
|
70
|
+
value in ``[0, 1]``; ``prior`` is the sub-threshold filename nudge already scaled by ``β``. Among
|
|
71
|
+
valid maps, one with more single-role-eligible files in their sole role wins; ties break on score.
|
|
72
|
+
"""
|
|
73
|
+
unfillable = [
|
|
74
|
+
r for r in range(n_roles) if n_files == 0 or all(forbidden[r][f] for f in range(n_files))
|
|
75
|
+
]
|
|
76
|
+
if n_files < n_roles or unfillable:
|
|
77
|
+
return AssignmentResult(
|
|
78
|
+
valid=False,
|
|
79
|
+
unassigned_files=list(range(n_files)),
|
|
80
|
+
unfillable_roles=unfillable,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# ``exclusive[r][f]``: file f is eligible for role r and for no other role — it can be placed
|
|
84
|
+
# nowhere else, so coverage demands r be represented by one of its exclusive files when it has any.
|
|
85
|
+
n_eligible = [sum(not forbidden[r][f] for r in range(n_roles)) for f in range(n_files)]
|
|
86
|
+
exclusive = [
|
|
87
|
+
[(not forbidden[r][f]) and n_eligible[f] == 1 for f in range(n_files)]
|
|
88
|
+
for r in range(n_roles)
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
if _n_injective(n_files, n_roles) <= _BRUTE_CAP:
|
|
92
|
+
chosen = _brute(n_roles, n_files, score, forbidden, prior, exclusive)
|
|
93
|
+
else:
|
|
94
|
+
chosen = _hungarian_assign(n_roles, n_files, score, forbidden, prior, exclusive)
|
|
95
|
+
|
|
96
|
+
if chosen is None: # a forbidden pattern blocks every full injective map
|
|
97
|
+
return AssignmentResult(valid=False, unassigned_files=list(range(n_files)))
|
|
98
|
+
|
|
99
|
+
mapping, raw = chosen
|
|
100
|
+
assigned = set(mapping.values())
|
|
101
|
+
unassigned = [f for f in range(n_files) if f not in assigned]
|
|
102
|
+
return AssignmentResult(valid=True, mapping=mapping, unassigned_files=unassigned, raw=raw)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _brute(
|
|
106
|
+
n_roles: int,
|
|
107
|
+
n_files: int,
|
|
108
|
+
score: list[list[float]],
|
|
109
|
+
forbidden: list[list[bool]],
|
|
110
|
+
prior: list[list[float]],
|
|
111
|
+
exclusive: list[list[bool]],
|
|
112
|
+
) -> tuple[dict[int, int], float] | None:
|
|
113
|
+
best_key: tuple[int, float] | None = None # (coverage, raw), lexicographic
|
|
114
|
+
best: tuple[int, ...] | None = None
|
|
115
|
+
for perm in permutations(range(n_files), n_roles):
|
|
116
|
+
if any(forbidden[r][perm[r]] for r in range(n_roles)):
|
|
117
|
+
continue
|
|
118
|
+
coverage = sum(exclusive[r][perm[r]] for r in range(n_roles))
|
|
119
|
+
raw = sum(score[r][perm[r]] + prior[r][perm[r]] for r in range(n_roles))
|
|
120
|
+
key = (coverage, raw)
|
|
121
|
+
if best_key is None or key > best_key:
|
|
122
|
+
best_key = key
|
|
123
|
+
best = perm
|
|
124
|
+
if best is None or best_key is None:
|
|
125
|
+
return None
|
|
126
|
+
return {r: best[r] for r in range(n_roles)}, best_key[1]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _hungarian_assign(
|
|
130
|
+
n_roles: int,
|
|
131
|
+
n_files: int,
|
|
132
|
+
score: list[list[float]],
|
|
133
|
+
forbidden: list[list[bool]],
|
|
134
|
+
prior: list[list[float]],
|
|
135
|
+
exclusive: list[list[bool]],
|
|
136
|
+
) -> tuple[dict[int, int], float] | None:
|
|
137
|
+
n = max(n_roles, n_files)
|
|
138
|
+
# square cost: minimize -(coverage_bonus + score + prior); forbidden -> _BIG; dummy cols -> 0. The
|
|
139
|
+
# coverage bonus (>> any score sum) makes a single-role-eligible file claim its sole role first; it
|
|
140
|
+
# is dropped from the reported ``raw``, which stays the honest Σ(score + prior) of the chosen map.
|
|
141
|
+
cost = [[0.0] * n for _ in range(n)]
|
|
142
|
+
for r in range(n_roles):
|
|
143
|
+
for f in range(n_files):
|
|
144
|
+
if forbidden[r][f]:
|
|
145
|
+
cost[r][f] = _BIG
|
|
146
|
+
else:
|
|
147
|
+
bonus = _COVERAGE_BONUS if exclusive[r][f] else 0.0
|
|
148
|
+
cost[r][f] = -(bonus + score[r][f] + prior[r][f])
|
|
149
|
+
col_for_row = _hungarian(cost)
|
|
150
|
+
mapping: dict[int, int] = {}
|
|
151
|
+
raw = 0.0
|
|
152
|
+
for r in range(n_roles):
|
|
153
|
+
f = col_for_row[r]
|
|
154
|
+
if f >= n_files or forbidden[r][f]: # forced onto a dummy or forbidden edge -> no valid A
|
|
155
|
+
return None
|
|
156
|
+
mapping[r] = f
|
|
157
|
+
raw += score[r][f] + prior[r][f]
|
|
158
|
+
return mapping, raw
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _hungarian(cost: list[list[float]]) -> list[int]:
|
|
162
|
+
"""O(n^3) min-cost perfect assignment on a square matrix; returns ``col_for_row[i]``."""
|
|
163
|
+
n = len(cost)
|
|
164
|
+
u = [0.0] * (n + 1)
|
|
165
|
+
v = [0.0] * (n + 1)
|
|
166
|
+
p = [0] * (n + 1) # p[j] = row matched to column j (1-indexed; 0 = unmatched)
|
|
167
|
+
way = [0] * (n + 1)
|
|
168
|
+
for i in range(1, n + 1):
|
|
169
|
+
p[0] = i
|
|
170
|
+
j0 = 0
|
|
171
|
+
minv = [inf] * (n + 1)
|
|
172
|
+
used = [False] * (n + 1)
|
|
173
|
+
while True:
|
|
174
|
+
used[j0] = True
|
|
175
|
+
i0 = p[j0]
|
|
176
|
+
delta = inf
|
|
177
|
+
j1 = -1
|
|
178
|
+
for j in range(1, n + 1):
|
|
179
|
+
if not used[j]:
|
|
180
|
+
cur = cost[i0 - 1][j - 1] - u[i0] - v[j]
|
|
181
|
+
if cur < minv[j]:
|
|
182
|
+
minv[j] = cur
|
|
183
|
+
way[j] = j0
|
|
184
|
+
if minv[j] < delta:
|
|
185
|
+
delta = minv[j]
|
|
186
|
+
j1 = j
|
|
187
|
+
for j in range(n + 1):
|
|
188
|
+
if used[j]:
|
|
189
|
+
u[p[j]] += delta
|
|
190
|
+
v[j] -= delta
|
|
191
|
+
else:
|
|
192
|
+
minv[j] -= delta
|
|
193
|
+
j0 = j1
|
|
194
|
+
if p[j0] == 0:
|
|
195
|
+
break
|
|
196
|
+
while j0:
|
|
197
|
+
j1 = way[j0]
|
|
198
|
+
p[j0] = p[j1]
|
|
199
|
+
j0 = j1
|
|
200
|
+
col_for_row = [0] * n
|
|
201
|
+
for j in range(1, n + 1):
|
|
202
|
+
if p[j]:
|
|
203
|
+
col_for_row[p[j] - 1] = j - 1
|
|
204
|
+
return col_for_row
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Content-addressed, resumable ``seqforge/`` artifacts: disk is state, context is cache.
|
|
2
|
+
|
|
3
|
+
Per-file :class:`Observation` keyed by its content-address (a bounded local key or a provider md5 —
|
|
4
|
+
never a whole-file scan; see :func:`~seqforge.probe.core._content_key`); the dataset
|
|
5
|
+
:class:`ResolveResult` keyed by ``dataset_id = sha256(sorted(file_shas) ⊕ kb_version)`` with
|
|
6
|
+
``probe_version`` / ``resolve_version`` folded in — so a probe or scorer change invalidates the cache
|
|
7
|
+
without a manual bump. :func:`resume_key` adds a stat-only pointer (``realpath+size+mtime`` →
|
|
8
|
+
``dataset_id``) so an unchanged re-run rebuilds the answer without reading a FASTQ byte. Every write is
|
|
9
|
+
atomic-ish (write-then-rename); a corrupt/absent artifact reads back as ``None`` (recompute), never a
|
|
10
|
+
crash.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ..models.observation import Observation
|
|
22
|
+
from ..models.resolve import ResolveResult
|
|
23
|
+
from ..workspace import cache_dir
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def dataset_id(
|
|
27
|
+
file_shas: list[str], kb_version: str, probe_version: str, resolve_version: str
|
|
28
|
+
) -> str:
|
|
29
|
+
"""The content-addressed dataset id — stable under file order, sensitive to tool versions."""
|
|
30
|
+
key = "\n".join(sorted(file_shas))
|
|
31
|
+
key += f"|kb={kb_version}|probe={probe_version}|resolve={resolve_version}"
|
|
32
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resume_key(
|
|
36
|
+
paths: Iterable[str | Path], kb_version: str, probe_version: str, resolve_version: str
|
|
37
|
+
) -> str | None:
|
|
38
|
+
"""A stat-only key over the input files: same (realpath, size, mtime) set -> same key.
|
|
39
|
+
|
|
40
|
+
This is the LOCAL "did anything change since the last run?" check that lets a re-run skip probing
|
|
41
|
+
and read ZERO FASTQ bytes. It is deliberately NOT the content-addressed :func:`dataset_id` (which
|
|
42
|
+
needs the bytes): mtime is the standard cheap heuristic, and ``--no-cache`` is the escape hatch.
|
|
43
|
+
Returns ``None`` if any file is missing — nothing to resume against, so probe afresh.
|
|
44
|
+
"""
|
|
45
|
+
parts: list[str] = []
|
|
46
|
+
for p in paths:
|
|
47
|
+
try:
|
|
48
|
+
st = os.stat(p)
|
|
49
|
+
except OSError:
|
|
50
|
+
return None
|
|
51
|
+
parts.append(f"{os.path.realpath(p)}|{st.st_size}|{st.st_mtime_ns}")
|
|
52
|
+
key = "\n".join(sorted(parts))
|
|
53
|
+
key += f"|kb={kb_version}|probe={probe_version}|resolve={resolve_version}"
|
|
54
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Cache:
|
|
58
|
+
"""Reader/writer for the ``seqforge/cache/`` artifact tree rooted at a workspace.
|
|
59
|
+
|
|
60
|
+
These are content-addressed, resumable, and safe to delete — so they live under ``cache/``, not
|
|
61
|
+
beside the manifest a human reads.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, workspace: str | Path) -> None:
|
|
65
|
+
self.root = cache_dir(workspace)
|
|
66
|
+
|
|
67
|
+
def _obs_path(self, sha: str) -> Path:
|
|
68
|
+
return self.root / "observations" / f"{sha}.json"
|
|
69
|
+
|
|
70
|
+
def _resolve_path(self, ds_id: str) -> Path:
|
|
71
|
+
return self.root / "candidates" / f"{ds_id}.json"
|
|
72
|
+
|
|
73
|
+
def read_observation(self, sha: str) -> Observation | None:
|
|
74
|
+
path = self._obs_path(sha)
|
|
75
|
+
if not path.is_file():
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
return Observation.model_validate_json(path.read_text())
|
|
79
|
+
except (ValueError, OSError):
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def write_observation(self, obs: Observation) -> None:
|
|
83
|
+
self._write(self._obs_path(obs.file.sha256), obs.model_dump_json(indent=2))
|
|
84
|
+
|
|
85
|
+
def read_resolve(self, ds_id: str) -> ResolveResult | None:
|
|
86
|
+
path = self._resolve_path(ds_id)
|
|
87
|
+
if not path.is_file():
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
return ResolveResult.model_validate_json(path.read_text())
|
|
91
|
+
except (ValueError, OSError):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def write_resolve(self, ds_id: str, result: ResolveResult) -> None:
|
|
95
|
+
self._write(self._resolve_path(ds_id), result.model_dump_json(indent=2))
|
|
96
|
+
|
|
97
|
+
def _resume_path(self, key: str) -> Path:
|
|
98
|
+
return self.root / "resume" / f"{key}.json"
|
|
99
|
+
|
|
100
|
+
def read_resume(self, key: str) -> dict[str, object] | None:
|
|
101
|
+
"""Read a stat-keyed resume pointer (see :func:`resume_key`); ``None`` if absent/corrupt."""
|
|
102
|
+
path = self._resume_path(key)
|
|
103
|
+
if not path.is_file():
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
data = json.loads(path.read_text())
|
|
107
|
+
except (ValueError, OSError):
|
|
108
|
+
return None
|
|
109
|
+
return data if isinstance(data, dict) else None
|
|
110
|
+
|
|
111
|
+
def write_resume(self, key: str, payload: dict[str, object]) -> None:
|
|
112
|
+
self._write(self._resume_path(key), json.dumps(payload, indent=2))
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _write(path: Path, text: str) -> None:
|
|
116
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
118
|
+
tmp.write_text(text)
|
|
119
|
+
tmp.replace(path)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Confusability helpers — the ``§12`` benign rule and its ``backend_identical`` biconditional.
|
|
2
|
+
|
|
3
|
+
Two technologies are **processing-equivalent** iff, after resolving every ``{onlist:alias}`` to its
|
|
4
|
+
registry name and normalizing key order **and** the read->role placement, their ``backend.params``
|
|
5
|
+
canonical forms are byte-equal. Including role placement matters: two techs that differ only in
|
|
6
|
+
*which* read is biological must not be called benign. The CI biconditional is
|
|
7
|
+
``backend_identical(A, B) <=> declared processing_equivalent`` (§2.4); this module provides the
|
|
8
|
+
``backend_identical`` primitive and the declared-relationship lookups the resolver consults at
|
|
9
|
+
runtime to decide a benign record-both vs a divergent tie.
|
|
10
|
+
|
|
11
|
+
Since counting moved out of ``backend.params``, this predicate means exactly *"these two
|
|
12
|
+
chemistries parse reads identically"* — which is what ``processing_equivalent`` should have meant all
|
|
13
|
+
along, and it makes the rule **stronger**, not weaker: two specs differing only in what they count are
|
|
14
|
+
no longer distinguishable here, because that difference is no longer a chemistry fact at all. It is
|
|
15
|
+
the processing manifest's to make, per dataset.
|
|
16
|
+
|
|
17
|
+
**List order is significant** and is never normalized; see :func:`_resolve_value`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
|
|
24
|
+
from ..kb.schema import Spec
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _role_placement(spec: Spec) -> list[str]:
|
|
28
|
+
"""Canonical biological/technical read ordering (``readFilesIn`` order is cDNA read first)."""
|
|
29
|
+
kinds: list[str] = []
|
|
30
|
+
for read in spec.reads:
|
|
31
|
+
el_types = {el.type for el in read.elements}
|
|
32
|
+
if el_types & {"cdna", "gdna"}:
|
|
33
|
+
kinds.append(f"bio:{read.strand}")
|
|
34
|
+
elif "barcode" in el_types:
|
|
35
|
+
kinds.append("barcode")
|
|
36
|
+
else:
|
|
37
|
+
kinds.append("other")
|
|
38
|
+
return sorted(kinds)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def canonical_backend(spec: Spec) -> str:
|
|
42
|
+
"""A canonical, onlist-resolved, role-aware serialization of a spec's ``backend``.
|
|
43
|
+
|
|
44
|
+
An ABSTRACT family node has no backend, so it canonicalizes to a per-id sentinel: no two nodes
|
|
45
|
+
share it, so a classifier is never ``backend_identical`` to — and thus never a false
|
|
46
|
+
processing-equivalent twin of — a leaf.
|
|
47
|
+
"""
|
|
48
|
+
if spec.backend is None:
|
|
49
|
+
return json.dumps({"abstract_node": spec.identity.id}, sort_keys=True)
|
|
50
|
+
resolved: dict[str, object] = {}
|
|
51
|
+
for key, value in spec.backend.params.items():
|
|
52
|
+
resolved[key] = _resolve_value(value, spec)
|
|
53
|
+
# Fold in the DERIVED geometry (soloCB/UMIposition, soloAdapterSequence). Those are byte-decided
|
|
54
|
+
# parse facts read off the element coordinates, not declared here — and they are exactly where two
|
|
55
|
+
# chemistries with identical DECLARED params can still parse reads differently. The original BD bead
|
|
56
|
+
# and the Enhanced-96 bead share soloType, whitelists and strand; only their geometry differs (fixed
|
|
57
|
+
# offsets vs an adapter-anchored, diversity-insert-staggered frame). Comparing declared params alone
|
|
58
|
+
# called them byte-identical -> §12-benign -> one config for both. Local import: `compose` reads the
|
|
59
|
+
# KB, so importing it at module load would knot resolve<->compose. (#43)
|
|
60
|
+
from ..compose.params import derived_params
|
|
61
|
+
|
|
62
|
+
resolved.update(derived_params(spec))
|
|
63
|
+
payload = {
|
|
64
|
+
"module": spec.backend.module,
|
|
65
|
+
"params": resolved,
|
|
66
|
+
"placement": _role_placement(spec),
|
|
67
|
+
}
|
|
68
|
+
return json.dumps(payload, sort_keys=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_value(value: object, spec: Spec) -> object:
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
return _resolve_token(value, spec)
|
|
74
|
+
if isinstance(value, list):
|
|
75
|
+
# ORDER IS PRESERVED, and it must be. This used to sort, justified by exactly one comment:
|
|
76
|
+
# "normalize list order so soloFeatures=[Gene,GeneFull] == [GeneFull,Gene]". soloFeatures has
|
|
77
|
+
# since moved to the processing manifest (it says what to COUNT, not how to parse), and
|
|
78
|
+
# with it the only reason the sort existed.
|
|
79
|
+
#
|
|
80
|
+
# What it would sort NOW is the only list-valued parse param left: splitseq's
|
|
81
|
+
# `soloCBwhitelist: [round1, round2, round3]` — which is POSITIONAL. The rounds map to CB
|
|
82
|
+
# positions in order. Sorting it made `backend_identical` return True for a spec against
|
|
83
|
+
# itself-with-rounds-permuted: two chemistries that parse reads DIFFERENTLY, declared byte-
|
|
84
|
+
# equal, hence §12-benign, hence one config emitted for both. It never fired only by the
|
|
85
|
+
# alphabetical accident that round1 < round2 < round3.
|
|
86
|
+
return [_resolve_token(v, spec) if isinstance(v, str) else v for v in value]
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_token(value: str, spec: Spec) -> str:
|
|
91
|
+
if value.startswith("{onlist:") and value.endswith("}"):
|
|
92
|
+
alias = value[len("{onlist:") : -1]
|
|
93
|
+
ref = spec.onlists.get(alias)
|
|
94
|
+
return f"registry:{ref.registry}" if ref else value
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def backend_identical(a: Spec, b: Spec) -> bool:
|
|
99
|
+
"""True iff two specs compile to byte-equal, onlist-resolved, role-aware backends."""
|
|
100
|
+
return canonical_backend(a) == canonical_backend(b)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def accepts_at_rungs_0_2(spec: Spec, probes: list[object]) -> bool:
|
|
104
|
+
"""Would ``spec`` claim this data using only the CHEAP probes — no onlist, no network?
|
|
105
|
+
|
|
106
|
+
The onlist is withheld by handing the evaluator an **empty registry**, so every
|
|
107
|
+
``onlist_hit_rate`` test abstains and the verdict rests on geometry, segmentation, distinct-value
|
|
108
|
+
ratios and header grammar alone. That is precisely rungs 0-2 (§5), expressed by removing the
|
|
109
|
+
rung-3 evidence rather than by reimplementing the scorer without it.
|
|
110
|
+
|
|
111
|
+
This is the primitive behind :func:`rung02_separable`, and it is why "ask the human" can be a
|
|
112
|
+
computed property instead of a prompt hope.
|
|
113
|
+
"""
|
|
114
|
+
from ..io import OnlistRegistry
|
|
115
|
+
from .scoring import build_tech_evaluation
|
|
116
|
+
from .window import WindowProbe
|
|
117
|
+
|
|
118
|
+
wps = [p for p in probes if isinstance(p, WindowProbe)]
|
|
119
|
+
return build_tech_evaluation(spec, wps, OnlistRegistry(offline=True)).valid
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def rung02_separable(a: Spec, a_probes: list[object], b: Spec, b_probes: list[object]) -> bool:
|
|
123
|
+
"""Do the cheap probes tell these two chemistries apart at all? (design §2.4, fact 1)
|
|
124
|
+
|
|
125
|
+
Separable iff **neither** spec accepts the other's data on geometry alone. If A would happily
|
|
126
|
+
claim B's reads, no amount of scoring rigour separates them below rung 3 — the honest thing is
|
|
127
|
+
for the KB to *say so* via ``confusable_with``, so the resolver knows to reach for the onlist or
|
|
128
|
+
a human rather than picking the alphabetically-luckier entry.
|
|
129
|
+
|
|
130
|
+
Some distinctions are provably undecidable from reads (10x 3' and 5' share CB/UMI geometry;
|
|
131
|
+
inDrop v2 and v3 share oligos). The system must KNOW that rather than guess, which is the whole
|
|
132
|
+
point of computing this instead of hand-maintaining a truth table.
|
|
133
|
+
"""
|
|
134
|
+
return not (accepts_at_rungs_0_2(a, b_probes) or accepts_at_rungs_0_2(b, a_probes))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def declared_equivalents(spec: Spec) -> set[str]:
|
|
138
|
+
"""Ids the spec declares as ``processing_equivalent`` twins (benign, ``§12`` record-both)."""
|
|
139
|
+
return {c.id for c in spec.confusable_with if c.relationship == "processing_equivalent"}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def declared_divergent(spec: Spec) -> set[str]:
|
|
143
|
+
"""Ids the spec declares as ``processing_divergent`` (a real disagreement to decide)."""
|
|
144
|
+
return {c.id for c in spec.confusable_with if c.relationship == "processing_divergent"}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_processing_equivalent(a: Spec, b_id: str) -> bool:
|
|
148
|
+
"""Does ``a`` declare ``b_id`` as a processing-equivalent twin?"""
|
|
149
|
+
return b_id in declared_equivalents(a)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---- tree-sourced confusability: siblings replace hand-declared divergent cliques ----
|
|
153
|
+
def share_parent(specs: dict[str, Spec], a: str, b: str) -> bool:
|
|
154
|
+
"""True iff ``a`` and ``b`` are siblings — the same non-null parent in the KB tree."""
|
|
155
|
+
pa = specs[a].parent if a in specs else None
|
|
156
|
+
pb = specs[b].parent if b in specs else None
|
|
157
|
+
return pa is not None and pa == pb
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def is_tree_kin(specs: dict[str, Spec], a: str, b: str) -> bool:
|
|
161
|
+
"""True iff ``a`` and ``b`` are parent-child or siblings — a confusability the tree DECLARES.
|
|
162
|
+
|
|
163
|
+
A divergent sibling clique (v2/v3/v3.1) collapses to one ``parent`` link, so the under-declaration
|
|
164
|
+
guard treats tree kin the way it treats an explicit ``confusable_with`` edge: already declared.
|
|
165
|
+
"""
|
|
166
|
+
if a not in specs or b not in specs:
|
|
167
|
+
return False
|
|
168
|
+
if specs[a].parent == b or specs[b].parent == a:
|
|
169
|
+
return True
|
|
170
|
+
return share_parent(specs, a, b)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def sibling_decided_by(specs: dict[str, Spec], a: str, b: str) -> list[str]:
|
|
174
|
+
"""If ``a`` and ``b`` are siblings, the mechanisms their parent declares separate its children.
|
|
175
|
+
|
|
176
|
+
This is where the divergent-tie question now reads ``decidable_by`` from — the parent's
|
|
177
|
+
``children_decided_by`` — instead of the per-sibling ``distinguishable_by`` edge that was deleted.
|
|
178
|
+
"""
|
|
179
|
+
if not share_parent(specs, a, b):
|
|
180
|
+
return []
|
|
181
|
+
parent = specs[a].parent
|
|
182
|
+
if parent is None or parent not in specs:
|
|
183
|
+
return []
|
|
184
|
+
return [m for m in specs[parent].children_decided_by if m != "none"]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _root_of(specs: dict[str, Spec], tech: str) -> str:
|
|
188
|
+
"""The family-root ancestor of ``tech`` — walk ``parent`` links to the top of its tree."""
|
|
189
|
+
cur = tech
|
|
190
|
+
seen: set[str] = set()
|
|
191
|
+
while cur in specs and cur not in seen:
|
|
192
|
+
parent = specs[cur].parent
|
|
193
|
+
if parent is None:
|
|
194
|
+
break
|
|
195
|
+
seen.add(cur)
|
|
196
|
+
cur = parent
|
|
197
|
+
return cur
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def same_family(specs: dict[str, Spec], a: str, b: str) -> bool:
|
|
201
|
+
"""True iff ``a`` and ``b`` share a family root — the assay family a paper reliably names.
|
|
202
|
+
|
|
203
|
+
The policy this encodes: harvest is trusted at the FAMILY level (10x 3' gene-expression), the bytes
|
|
204
|
+
at the LEAF level (v2 vs v3). A within-family disagreement (asserted v2, observed v3) is therefore
|
|
205
|
+
not a blocking conflict — the bytes decide the leaf. It is broader than ``is_tree_kin`` (which is
|
|
206
|
+
parent-child OR siblings only): two cousins under a deeper tree are the same family yet not kin.
|
|
207
|
+
|
|
208
|
+
Every assay family is its own root today (``10x-3p-gex``, ``bulk-rnaseq-pe``, ``splitseq``,
|
|
209
|
+
``bd-rhapsody-wta`` are distinct roots), so a shared root IS the same family. A future super-root
|
|
210
|
+
over two genuinely different families would need a KB-lint guard against over-suppression; none
|
|
211
|
+
exists now, and none is needed while roots and families coincide.
|
|
212
|
+
"""
|
|
213
|
+
if a not in specs or b not in specs:
|
|
214
|
+
return False
|
|
215
|
+
return _root_of(specs, a) == _root_of(specs, b)
|