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
seqforge/evals/grade.py
ADDED
|
@@ -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
|