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,300 @@
1
+ """Grading — turn (expected, actual) into the metrics brief §9 actually asks for.
2
+
3
+ The whole harness reduces to one asymmetry: **not all failures cost the same.** A refusal is cheap —
4
+ a human looks at it. A confidently wrong manifest is expensive — it silently poisons a training
5
+ corpus and nothing downstream ever asks again. So the outcome classes are not a pass/fail bit but a
6
+ 3x3 confusion, and one cell is singled out.
7
+
8
+ Actual outcome comes from the uniform exit contract, never re-derived: ``0`` decide, ``3`` refuse,
9
+ ``4`` ask.
10
+
11
+ ====================== ============== ============== ==============
12
+ expected \\ actual decide refuse ask
13
+ ====================== ============== ============== ==============
14
+ decide (values match) correct false_refuse over_ask
15
+ decide (values differ) FALSE_ACCEPT false_refuse over_ask
16
+ refuse FALSE_ACCEPT correct* mis_triage
17
+ ask FALSE_ACCEPT false_refuse correct*
18
+ ====================== ============== ============== ==============
19
+
20
+ ``*`` right outcome, wrong reason (wrong BlockerCode / wrong conflict) grades ``wrong_reason``: it is
21
+ not a false-accept — the pipeline still stopped — but it is not correct either, because the human
22
+ gets sent the wrong way. Counting it as a pass would let a blocker's *meaning* rot untested.
23
+
24
+ Three cells deserve their reasoning stated, because each is a judgement call:
25
+
26
+ - **expected=ask, actual=decide is a false_accept, not a separate "missed question".** The brief calls
27
+ failing to ask a needed question a hard fail. Its *mechanism* is a silent pick, which is exactly
28
+ what false-accept measures. It is additionally flagged ``missed_question`` for the report.
29
+ - **expected=decide, actual=ask is over_ask, not a false-refuse.** Nothing wrong entered the manifest;
30
+ a human was asked a question code could have settled. That is a cost regression, tracked under
31
+ "questions asked (fewer is better)" — not a correctness failure.
32
+ - **expected=ask, actual=refuse is false_refuse.** It stopped without giving the human a decidable
33
+ question. The effect on throughput is a block, so it is counted as one.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from dataclasses import dataclass, field
39
+ from enum import StrEnum
40
+ from typing import Any
41
+
42
+ from ..models.resolve import MetadataResolution, ResolveResult
43
+
44
+
45
+ class Grade(StrEnum):
46
+ """How a case came out. ``FALSE_ACCEPT`` is the metric that matters (brief §9)."""
47
+
48
+ CORRECT = "correct"
49
+ #: Produced a decision that is wrong, or produced one at all when it should have stopped.
50
+ FALSE_ACCEPT = "false_accept"
51
+ #: Blocked on something it should have decided or asked about.
52
+ FALSE_REFUSE = "false_refuse"
53
+ #: Asked a human what code could have settled. A cost regression, not a correctness one.
54
+ OVER_ASK = "over_ask"
55
+ #: Right outcome class, wrong reason: wrong BlockerCode or wrong conflict.
56
+ WRONG_REASON = "wrong_reason"
57
+ #: Refused when it should have asked, or vice versa — both stop, but send the human elsewhere.
58
+ MIS_TRIAGE = "mis_triage"
59
+
60
+
61
+ def outcome_of(exit_code: int) -> str:
62
+ """Map the uniform exit contract onto an outcome class. Never re-derived from the result body."""
63
+ return {0: "decide", 3: "refuse", 4: "ask"}.get(exit_code, "error")
64
+
65
+
66
+ @dataclass
67
+ class FieldCheck:
68
+ path: str
69
+ expected: Any
70
+ actual: Any
71
+ ok: bool
72
+
73
+
74
+ @dataclass
75
+ class CaseGrade:
76
+ """One case's verdict: the grade, why, and the field-level detail behind it."""
77
+
78
+ case_id: str
79
+ grade: Grade
80
+ expected_outcome: str
81
+ actual_outcome: str
82
+ fields: list[FieldCheck] = field(default_factory=list)
83
+ notes: list[str] = field(default_factory=list)
84
+ missed_question: bool = False
85
+
86
+ @property
87
+ def ok(self) -> bool:
88
+ return self.grade is Grade.CORRECT
89
+
90
+ def to_json(self) -> dict[str, Any]:
91
+ return {
92
+ "case": self.case_id,
93
+ "grade": self.grade.value,
94
+ "expected": self.expected_outcome,
95
+ "actual": self.actual_outcome,
96
+ "fields": [
97
+ {"path": f.path, "expected": f.expected, "actual": f.actual, "ok": f.ok}
98
+ for f in self.fields
99
+ ],
100
+ "notes": self.notes,
101
+ "missed_question": self.missed_question,
102
+ }
103
+
104
+
105
+ def grade_case(
106
+ case_id: str,
107
+ expected: Any,
108
+ result: ResolveResult,
109
+ exit_code: int,
110
+ labels: dict[str, str],
111
+ metadata: MetadataResolution | None = None,
112
+ ) -> CaseGrade:
113
+ """Grade one resolve outcome against a case's ``expected.yaml``.
114
+
115
+ ``labels`` maps file sha256 -> a stable label (``R1``/``R2``) so role assertions can be written
116
+ against the recipe's read ids rather than machine-dependent hashes.
117
+
118
+ ``metadata`` is the second resolver's answer, and until it existed the harness could not see a
119
+ sample at all: it graded a ``ResolveResult``, which has candidates and conflicts and no samples,
120
+ so every sample-level claim in a pre-registration was un-checkable prose. design.md §9 named
121
+ that gap ("the grader cannot express … SRX→sample mapping") and it stayed named for as long as
122
+ nothing produced samples to grade.
123
+ """
124
+ actual = outcome_of(exit_code)
125
+ exp = expected.outcome
126
+ notes: list[str] = []
127
+
128
+ if actual == "error":
129
+ return CaseGrade(case_id, Grade.FALSE_REFUSE, exp, actual, notes=[f"exit {exit_code}"])
130
+
131
+ # Field checks run on `ask` too, not just `decide`: design §3.5 says the library section takes the
132
+ # observed value while the conflict stays attached. So "what did the library land on" is a real
133
+ # assertion even when the case correctly stops to ask, and skipping it would leave the value
134
+ # untested on exactly the path where metadata is known to be lying.
135
+ checks = (
136
+ _check_fields(expected.fields, result, labels, metadata)
137
+ if actual in ("decide", "ask")
138
+ else []
139
+ )
140
+
141
+ if exp == "decide":
142
+ if actual == "decide":
143
+ bad = [c for c in checks if not c.ok]
144
+ if bad:
145
+ notes += [f"{c.path}: expected {c.expected!r}, got {c.actual!r}" for c in bad]
146
+ # A decision that disagrees with ground truth IS the corpus-poisoning failure.
147
+ return CaseGrade(case_id, Grade.FALSE_ACCEPT, exp, actual, checks, notes)
148
+ return CaseGrade(case_id, Grade.CORRECT, exp, actual, checks, notes)
149
+ if actual == "refuse":
150
+ notes.append(f"blocked: {_codes(result)}")
151
+ return CaseGrade(case_id, Grade.FALSE_REFUSE, exp, actual, checks, notes)
152
+ notes.append("asked a question code should have settled")
153
+ return CaseGrade(case_id, Grade.OVER_ASK, exp, actual, checks, notes)
154
+
155
+ if exp == "refuse":
156
+ if actual == "decide":
157
+ notes.append(f"guessed {_tech(result)!r} where refusal was correct")
158
+ return CaseGrade(case_id, Grade.FALSE_ACCEPT, exp, actual, checks, notes)
159
+ if actual == "ask":
160
+ notes.append("asked instead of blocking")
161
+ return CaseGrade(case_id, Grade.MIS_TRIAGE, exp, actual, checks, notes)
162
+ got = _codes(result)
163
+ missing = [c for c in expected.blockers if c not in got]
164
+ if missing:
165
+ notes.append(f"expected blocker(s) {missing}, got {sorted(got)}")
166
+ return CaseGrade(case_id, Grade.WRONG_REASON, exp, actual, checks, notes)
167
+ return CaseGrade(case_id, Grade.CORRECT, exp, actual, checks, notes)
168
+
169
+ # exp == "ask"
170
+ if actual == "decide":
171
+ notes.append(f"silently picked {_tech(result)!r} instead of surfacing a question")
172
+ return CaseGrade(
173
+ case_id, Grade.FALSE_ACCEPT, exp, actual, checks, notes, missed_question=True
174
+ )
175
+ if actual == "refuse":
176
+ notes.append(f"blocked ({_codes(result)}) instead of asking an answerable question")
177
+ return CaseGrade(case_id, Grade.FALSE_REFUSE, exp, actual, checks, notes)
178
+ if expected.conflict is not None:
179
+ ok, why = _check_conflict(expected.conflict, result)
180
+ if not ok:
181
+ notes.append(why)
182
+ return CaseGrade(case_id, Grade.WRONG_REASON, exp, actual, checks, notes)
183
+ bad = [c for c in checks if not c.ok]
184
+ if bad:
185
+ # It stopped to ask (so: not a false accept — nothing was silently committed), but the value
186
+ # the library landed on is wrong. The human gets the right question about the wrong state.
187
+ notes += [f"{c.path}: expected {c.expected!r}, got {c.actual!r}" for c in bad]
188
+ return CaseGrade(case_id, Grade.WRONG_REASON, exp, actual, checks, notes)
189
+ return CaseGrade(case_id, Grade.CORRECT, exp, actual, checks, notes)
190
+
191
+
192
+ def _check_conflict(want: Any, result: ResolveResult) -> tuple[bool, str]:
193
+ open_conflicts = [c for c in result.conflicts if c.status == "open"]
194
+ if not open_conflicts and not result.questions:
195
+ return False, "exit 4 but no open conflict or question"
196
+ kinds = {c.kind for c in open_conflicts}
197
+ if want.kind and open_conflicts and want.kind not in kinds:
198
+ return False, f"expected conflict kind {want.kind!r}, got {sorted(kinds)}"
199
+ if want.field:
200
+ seen = {c.field for c in open_conflicts} | {q.field for q in result.questions}
201
+ if want.field not in seen:
202
+ return False, f"expected conflict on {want.field!r}, got {sorted(x for x in seen if x)}"
203
+ if want.positions:
204
+ matching = [c for c in open_conflicts if not want.field or c.field == want.field]
205
+ got = {p.basis: p.value for c in matching for p in c.positions}
206
+ if got != want.positions:
207
+ return False, f"expected positions {want.positions}, got {got}"
208
+ return True, ""
209
+
210
+
211
+ def _check_fields(
212
+ want: dict[str, Any],
213
+ result: ResolveResult,
214
+ labels: dict[str, str],
215
+ metadata: MetadataResolution | None = None,
216
+ ) -> list[FieldCheck]:
217
+ checks: list[FieldCheck] = []
218
+ top = result.candidates[0] if result.candidates else None
219
+ for path, expected in sorted(want.items()):
220
+ actual = _extract_field(path, result, top, labels, metadata)
221
+ checks.append(FieldCheck(path, expected, actual, _equal(expected, actual)))
222
+ return checks
223
+
224
+
225
+ def _extract_field(
226
+ path: str,
227
+ result: ResolveResult,
228
+ top: Any,
229
+ labels: dict[str, str],
230
+ metadata: MetadataResolution | None = None,
231
+ ) -> Any:
232
+ if path.startswith("experiment."):
233
+ return _extract_experiment_field(path, metadata)
234
+ if top is None:
235
+ return None
236
+ if path == "library.chemistry":
237
+ return top.technology
238
+ if path == "library.equivalence_members":
239
+ return sorted(top.equivalence_members)
240
+ if path == "rung":
241
+ return result.rung_reached
242
+ if path.startswith("library.roles."):
243
+ role = path.split(".", 2)[2]
244
+ sha = top.role_assignment.assignment.get(role)
245
+ return labels.get(sha or "", sha)
246
+ return f"<unsupported field {path}>"
247
+
248
+
249
+ def _extract_experiment_field(path: str, metadata: MetadataResolution | None) -> Any:
250
+ """The sample-level half of the grading surface. ``None`` when the resolver said nothing.
251
+
252
+ ``None`` rather than an ``<unsupported field>`` sentinel, and the difference is the whole reason
253
+ this exists: a pre-registration saying `tissue: Neurons` must FAIL against a manifest that says
254
+ null. A sentinel string would also fail, but it would fail for the wrong reason and would keep
255
+ failing after the bug was fixed.
256
+
257
+ Two shapes, both dotted:
258
+
259
+ - ``experiment.samples.<sample_id>.<attr>`` — one sample's attribute. The sample id is the
260
+ archive's accession when a record was joined, so this is a claim about a specific BioSample.
261
+ - ``experiment.samples.*.<attr>`` — the attribute across EVERY sample, sorted. This is what a
262
+ pre-registration usually wants: "tissue=Neurons" was never a claim about one of the six.
263
+ - ``experiment.organism`` / ``experiment.study.<field>``.
264
+ """
265
+ if metadata is None:
266
+ return None
267
+ if path == "experiment.organism":
268
+ return metadata.organism.value if metadata.organism is not None else None
269
+ if path.startswith("experiment.study."):
270
+ field = path.split(".", 2)[2]
271
+ return getattr(metadata.project, field, None) if metadata.project else None
272
+ if path.startswith("experiment.samples."):
273
+ rest = path[len("experiment.samples.") :]
274
+ sample_id, _, attr = rest.rpartition(".")
275
+ if not sample_id: # `experiment.samples.tissue` with no subject names no sample
276
+ return f"<unsupported field {path}: name a sample id, or '*' for all of them>"
277
+ if sample_id == "*":
278
+ return sorted(
279
+ s.attributes[attr].value for s in metadata.samples if attr in s.attributes
280
+ )
281
+ for sample in metadata.samples:
282
+ if sample.sample_id == sample_id:
283
+ found = sample.attributes.get(attr)
284
+ return found.value if found is not None else None
285
+ return None
286
+ return f"<unsupported field {path}>"
287
+
288
+
289
+ def _equal(expected: Any, actual: Any) -> bool:
290
+ if isinstance(expected, list) and isinstance(actual, list):
291
+ return sorted(map(str, expected)) == sorted(map(str, actual))
292
+ return bool(expected == actual)
293
+
294
+
295
+ def _codes(result: ResolveResult) -> list[str]:
296
+ return sorted(str(getattr(b.code, "value", b.code)) for b in result.blockers)
297
+
298
+
299
+ def _tech(result: ResolveResult) -> str | None:
300
+ return result.candidates[0].technology if result.candidates else None