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,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)