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,550 @@
|
|
|
1
|
+
"""The metadata resolver: archive records + prose -> per-sample facts. A sibling of ``score``.
|
|
2
|
+
|
|
3
|
+
``score`` answers "what is this library?" from bytes. This answers "which sample is each file, and
|
|
4
|
+
what is that sample?" from records and prose. They are siblings rather than one stage because they
|
|
5
|
+
have the same ways of being wrong and therefore need the same discipline: both emit evidenced values,
|
|
6
|
+
and both can refuse outright. They differ in one place — the byte resolver surfaces an observed-vs-
|
|
7
|
+
asserted disagreement it will not arbitrate, while the metadata resolver decides a sample-attribute
|
|
8
|
+
disagreement (by precedence, or null) and only *notes* it — for the reason in "Where basis comes
|
|
9
|
+
from", below. What they must never do is talk to each other — see "the line", below.
|
|
10
|
+
|
|
11
|
+
**The join is code's, at every level.** run -> experiment -> sample -> project comes out of the
|
|
12
|
+
record, by accession; record-run -> file-on-disk comes out of the run accession in the filename or
|
|
13
|
+
the original filenames the record declares. A language model is never asked which sample a file is,
|
|
14
|
+
and never could be: it is not shown the files.
|
|
15
|
+
|
|
16
|
+
**The subject is the document.** A claim cannot name a sample — ``AssertionDraft`` has ``field``,
|
|
17
|
+
``value``, ``span``, and nothing else, and it stays that way. Instead each record level is rendered
|
|
18
|
+
as *its own document*, so the sample-level document contains that sample's fields and nothing else,
|
|
19
|
+
and "which sample" is answered by which file we handed the model. This is the trick ``instruct.py``
|
|
20
|
+
already ships for document role: code knows it because code chose it. The alternative — a ``subject``
|
|
21
|
+
field on the draft — would hand the model a new authority, and the two-jobs sentence would need
|
|
22
|
+
rewriting.
|
|
23
|
+
|
|
24
|
+
**Where basis comes from, and why it is not a vote.**
|
|
25
|
+
|
|
26
|
+
=========================================== =============== ================================
|
|
27
|
+
source basis because
|
|
28
|
+
=========================================== =============== ================================
|
|
29
|
+
a record's structured field (strain=CQ758) ``asserted`` the submitter declared it, of
|
|
30
|
+
this sample, in a typed slot
|
|
31
|
+
a model reading THIS sample's own prose ``asserted`` the document is about this
|
|
32
|
+
sample and nothing else
|
|
33
|
+
a model reading a DATASET-level document ``inferred`` the paper says it of the study;
|
|
34
|
+
(a paper, a README) that it holds of *this* sample
|
|
35
|
+
is our inference, not its claim
|
|
36
|
+
=========================================== =============== ================================
|
|
37
|
+
|
|
38
|
+
A disagreement across bases keeps the stronger basis's value (``asserted`` over ``inferred``); a
|
|
39
|
+
disagreement *within* one basis stores **no value at all**, because two equal authorities contradicting
|
|
40
|
+
each other is not something code may break, and a wrong value here is permanent (``experiment`` is
|
|
41
|
+
inside ``dataset_hash`` and the manifest is never rewritten). Either way the resolver has **decided** —
|
|
42
|
+
so the disagreement is a non-blocking ``warning``, not a refusal. Null-over-wrong is a value, not a
|
|
43
|
+
question for a human, and a single sample annotation is no reason to stop a whole dataset compiling.
|
|
44
|
+
Only the byte resolver's ``observed`` vs ``asserted`` conflict blocks: that one decides what the data
|
|
45
|
+
*is*, and code may not auto-pick it.
|
|
46
|
+
|
|
47
|
+
That asymmetry still catches the error span verification provably cannot. "We dissected neurons and body wall muscle"
|
|
48
|
+
entails ``tissue=neurons`` *and* ``tissue=muscle`` — both quotes are real, both pass span verification
|
|
49
|
+
and entailment. What separates them is that the record says ``Neurons``: it is a declaration about this
|
|
50
|
+
sample (``asserted``) and the paper's reading is our inference (``inferred``), so the record's value
|
|
51
|
+
stands and the paper's is surfaced as a warning a reader can see — never baked in as a fact a corpus
|
|
52
|
+
inherits, and never a refusal that stops the compile.
|
|
53
|
+
|
|
54
|
+
**No archive is the normal case, not the degraded one.** Most sequencing data has never had an
|
|
55
|
+
accession and never will: a freshly sequenced plate on a lab filesystem has no BioProject, no
|
|
56
|
+
BioSample, and no submitter alias. With no record, sample identity falls back to the run grouping
|
|
57
|
+
(filenames group; they always did) and sample facts come from whatever prose there is, or from
|
|
58
|
+
nothing. Nothing here refuses for lack of a record. The refusal is narrower and it is real: a record
|
|
59
|
+
that *exists* and does not account for the files on disk is a broken join, and half-joining would
|
|
60
|
+
leave some files with no sample while the manifest still read as though it described them all.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
from collections.abc import Sequence
|
|
66
|
+
from dataclasses import dataclass
|
|
67
|
+
|
|
68
|
+
from ..io.attributes import is_attribute
|
|
69
|
+
from ..models.assertion import Assertion
|
|
70
|
+
from ..models.base import Basis
|
|
71
|
+
from ..models.blocker import Blocker, BlockerCode, BlockerSubject, ValidationWarning
|
|
72
|
+
from ..models.evidenced import EvidencedStr, EvidencedTaxid
|
|
73
|
+
from ..models.observation import FileIdentity
|
|
74
|
+
from ..models.records import ArchiveRecord, ArchiveRecordSet
|
|
75
|
+
from ..models.resolve import MetadataResolution, ProjectFacts, ResolvedSample
|
|
76
|
+
from .group import run_key
|
|
77
|
+
|
|
78
|
+
#: Which authority wins when two sources disagree about one sample attribute. Never a vote and never
|
|
79
|
+
#: a confidence comparison: an LLM's self-reported confidence is advisory and would happily
|
|
80
|
+
#: outrank a database.
|
|
81
|
+
_BASIS_RANK: dict[Basis, int] = {
|
|
82
|
+
"observed": 3,
|
|
83
|
+
"user_confirmed": 2,
|
|
84
|
+
"asserted": 1,
|
|
85
|
+
"inferred": 0,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#: A record attribute that is real, useful, and NOT one of NCBI's 960 sample attributes. These are
|
|
89
|
+
#: facts about the record rather than the biology, so they are read by name here and never offered as
|
|
90
|
+
#: sample fields.
|
|
91
|
+
_RECORD_META = frozenset({"center_name", "biosample_package", "data_type", "submission_date"})
|
|
92
|
+
|
|
93
|
+
#: The prefix an assertion uses to name a sample attribute: ``experiment.samples.tissue``.
|
|
94
|
+
SAMPLE_FIELD_PREFIX = "experiment.samples."
|
|
95
|
+
|
|
96
|
+
#: What a document is ABOUT — set by code from which record produced it, never by the model and never
|
|
97
|
+
#: from the filename. ``dataset`` is a document handed to us for the whole pile of files (a paper, a
|
|
98
|
+
#: README); the others name one record.
|
|
99
|
+
DocScope = str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class DocumentSubject:
|
|
104
|
+
"""Which record a document was rendered from. Code's answer to "which sample is this about?".
|
|
105
|
+
|
|
106
|
+
Mirrors ``instruct.py``'s ``instruction_docs``: a set of ``doc_sha256`` that code assembled
|
|
107
|
+
because code chose the documents. Nothing here is derivable from the document's contents, and
|
|
108
|
+
that is the point — a spoofable subject would be worse than no subject.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
doc_sha256: str
|
|
112
|
+
scope: DocScope
|
|
113
|
+
#: The record's accession, when the scope names one. ``None`` for a dataset-level document.
|
|
114
|
+
subject: str | None = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class _Position:
|
|
119
|
+
"""One source's answer for one (sample, attribute), before anything is decided."""
|
|
120
|
+
|
|
121
|
+
value: str
|
|
122
|
+
basis: Basis
|
|
123
|
+
evidence: list[str]
|
|
124
|
+
confidence: float | None
|
|
125
|
+
rung: int
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def resolve_metadata(
|
|
129
|
+
*,
|
|
130
|
+
files: Sequence[FileIdentity],
|
|
131
|
+
records: ArchiveRecordSet | None = None,
|
|
132
|
+
assertions: Sequence[Assertion] = (),
|
|
133
|
+
subjects: Sequence[DocumentSubject] = (),
|
|
134
|
+
) -> MetadataResolution:
|
|
135
|
+
"""Resolve the files into samples, and the samples into facts.
|
|
136
|
+
|
|
137
|
+
Takes ``FileIdentity`` rather than ``Observation`` on purpose. This stage needs a basename and a
|
|
138
|
+
sha256 and nothing else, and being handed the probe's output would mean *promising* not to read
|
|
139
|
+
the signals in it. A signature that cannot see them keeps the promise structurally — see
|
|
140
|
+
:func:`_the_line` for why it is worth keeping.
|
|
141
|
+
"""
|
|
142
|
+
by_doc = {d.doc_sha256: d for d in subjects}
|
|
143
|
+
samples, blockers = _join(files, records)
|
|
144
|
+
subject_to_sample = _subject_to_sample(records)
|
|
145
|
+
|
|
146
|
+
verified = [a for a in assertions if a.span_verified and a.entailment_ok]
|
|
147
|
+
per_sample = {
|
|
148
|
+
s.sample_id: _positions_for(s, verified, by_doc, subject_to_sample) for s in samples
|
|
149
|
+
}
|
|
150
|
+
# Which attributes the archive/prose declares PER SAMPLE for anyone — proof the attribute varies by
|
|
151
|
+
# sample. A dataset-level (paper) claim may only fill an attribute nobody declares per-sample; the
|
|
152
|
+
# moment one sample owns a value for it, a blanket study-wide value is an unsafe guess for the
|
|
153
|
+
# samples left blank (#10). "sample-scoped" == any basis stronger than dataset-level `inferred`.
|
|
154
|
+
sample_scoped_attrs = frozenset(
|
|
155
|
+
name
|
|
156
|
+
for positions in per_sample.values()
|
|
157
|
+
for name, found in positions.items()
|
|
158
|
+
if any(p.basis != "inferred" for p in found)
|
|
159
|
+
)
|
|
160
|
+
resolved: list[ResolvedSample] = []
|
|
161
|
+
warnings: list[ValidationWarning] = []
|
|
162
|
+
for sample in samples:
|
|
163
|
+
positions = per_sample[sample.sample_id]
|
|
164
|
+
attrs, sample_warnings = _decide(sample.sample_id, positions, sample_scoped_attrs)
|
|
165
|
+
resolved.append(
|
|
166
|
+
ResolvedSample(
|
|
167
|
+
sample_id=sample.sample_id,
|
|
168
|
+
accession=sample.accession,
|
|
169
|
+
attributes=attrs,
|
|
170
|
+
file_shas=sample.file_shas,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
warnings.extend(sample_warnings)
|
|
174
|
+
|
|
175
|
+
return MetadataResolution(
|
|
176
|
+
samples=resolved,
|
|
177
|
+
project=_project_facts(records),
|
|
178
|
+
organism=_organism(records),
|
|
179
|
+
warnings=warnings,
|
|
180
|
+
blockers=blockers,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class _Sample:
|
|
186
|
+
"""A joined sample: who it is, which files carry it, and the record behind it (if any)."""
|
|
187
|
+
|
|
188
|
+
sample_id: str
|
|
189
|
+
accession: str | None
|
|
190
|
+
file_shas: list[str]
|
|
191
|
+
record: ArchiveRecord | None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _join(
|
|
195
|
+
files: Sequence[FileIdentity], records: ArchiveRecordSet | None
|
|
196
|
+
) -> tuple[list[_Sample], list[Blocker]]:
|
|
197
|
+
"""Files -> samples. The record when there is one, the filenames when there is not."""
|
|
198
|
+
if records is None or not records.at("run"):
|
|
199
|
+
return _join_by_filename(files), []
|
|
200
|
+
|
|
201
|
+
runs = records.at("run")
|
|
202
|
+
by_accession = {r.accession: r for r in runs}
|
|
203
|
+
by_filename: dict[str, ArchiveRecord] = {}
|
|
204
|
+
for declared in runs:
|
|
205
|
+
for name in declared.filenames:
|
|
206
|
+
by_filename[name] = declared
|
|
207
|
+
|
|
208
|
+
grouped: dict[str, list[str]] = {}
|
|
209
|
+
accession_of: dict[str, str | None] = {}
|
|
210
|
+
record_of: dict[str, ArchiveRecord | None] = {}
|
|
211
|
+
unclaimed: list[str] = []
|
|
212
|
+
|
|
213
|
+
for f in files:
|
|
214
|
+
basename = f.basename
|
|
215
|
+
run = by_accession.get(run_key(basename)) or by_filename.get(basename)
|
|
216
|
+
if run is None:
|
|
217
|
+
unclaimed.append(basename)
|
|
218
|
+
continue
|
|
219
|
+
sample = records.ancestor(run, "sample")
|
|
220
|
+
# A run whose sample record is missing still has an identity — its own accession. Degraded,
|
|
221
|
+
# and honest about it: the files are grouped correctly, we just cannot say what they are.
|
|
222
|
+
sample_id = sample.accession if sample is not None else run.accession
|
|
223
|
+
grouped.setdefault(sample_id, []).append(f.sha256)
|
|
224
|
+
accession_of[sample_id] = sample.accession if sample is not None else None
|
|
225
|
+
record_of[sample_id] = sample
|
|
226
|
+
|
|
227
|
+
if unclaimed:
|
|
228
|
+
return [], [_join_blocker(unclaimed, records)]
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
[
|
|
232
|
+
_Sample(
|
|
233
|
+
sample_id=sid,
|
|
234
|
+
accession=accession_of[sid],
|
|
235
|
+
file_shas=sorted(grouped[sid]),
|
|
236
|
+
record=record_of[sid],
|
|
237
|
+
)
|
|
238
|
+
for sid in sorted(grouped)
|
|
239
|
+
],
|
|
240
|
+
[],
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _join_by_filename(files: Sequence[FileIdentity]) -> list[_Sample]:
|
|
245
|
+
"""No record: the run grouping IS the sample identity.
|
|
246
|
+
|
|
247
|
+
This is the path for every dataset that never went near an archive, which is most of them. It is
|
|
248
|
+
exactly what the pipeline already did — filenames group, bytes assign — and it produces samples
|
|
249
|
+
with no facts, because there is nothing declaring any.
|
|
250
|
+
"""
|
|
251
|
+
grouped: dict[str, list[str]] = {}
|
|
252
|
+
for f in files:
|
|
253
|
+
grouped.setdefault(run_key(f.basename), []).append(f.sha256)
|
|
254
|
+
return [
|
|
255
|
+
_Sample(sample_id=sid, accession=None, file_shas=sorted(grouped[sid]), record=None)
|
|
256
|
+
for sid in sorted(grouped)
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _join_blocker(unclaimed: list[str], records: ArchiveRecordSet) -> Blocker:
|
|
261
|
+
declared = sorted({r.accession for r in records.at("run")})
|
|
262
|
+
return Blocker(
|
|
263
|
+
id="blk-record-join-incomplete",
|
|
264
|
+
code=BlockerCode.RECORD_JOIN_INCOMPLETE,
|
|
265
|
+
message=(
|
|
266
|
+
f"{records.query} declares {len(declared)} run(s) ({', '.join(declared[:6])}"
|
|
267
|
+
f"{', ...' if len(declared) > 6 else ''}), and {len(unclaimed)} file(s) on disk match "
|
|
268
|
+
f"none of them by run accession or by the original filenames the record declares: "
|
|
269
|
+
f"{', '.join(sorted(unclaimed)[:6])}{', ...' if len(unclaimed) > 6 else ''}. Refusing to "
|
|
270
|
+
f"half-join: the files it cannot place would silently get no sample facts, and a manifest "
|
|
271
|
+
f"that is confident about some samples and quiet about others reads as one about all."
|
|
272
|
+
),
|
|
273
|
+
remedy=(
|
|
274
|
+
"Either the files are not from this accession, or they were renamed after download. "
|
|
275
|
+
"Check the accession, or re-fetch with a tool that keeps the run accession in the "
|
|
276
|
+
"filename (`fasterq-dump --split-files` names them <RUN>_1.fastq.gz). To compile with no "
|
|
277
|
+
"sample facts at all, omit the accession — a dataset with no record is not an error."
|
|
278
|
+
),
|
|
279
|
+
subject=BlockerSubject(kind="dataset", ref="experiment.samples"),
|
|
280
|
+
evidence=sorted(unclaimed),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _subject_to_sample(records: ArchiveRecordSet | None) -> dict[str, str]:
|
|
285
|
+
"""Map any record accession (run, experiment, or sample) to its sample's accession.
|
|
286
|
+
|
|
287
|
+
A run or an experiment belongs to exactly one sample, so a claim from *its* document is a
|
|
288
|
+
declaration about *that* sample — the same standing a sample's own document has. This is the join
|
|
289
|
+
that lets ``_basis_for`` treat a run alias ("N2_wild_type", "daf-2 R3") as ``asserted`` of its
|
|
290
|
+
sample: the run names the sample by belonging to it, and code did the join, so no model was asked
|
|
291
|
+
"which sample". Without it a run document's claim maps to no sample and is silently discarded —
|
|
292
|
+
which is why the pilot's clearest genotype signal never reached the manifest.
|
|
293
|
+
"""
|
|
294
|
+
if records is None:
|
|
295
|
+
return {}
|
|
296
|
+
out: dict[str, str] = {}
|
|
297
|
+
for level in ("sample", "experiment", "run"):
|
|
298
|
+
for rec in records.at(level):
|
|
299
|
+
sample = rec if level == "sample" else records.ancestor(rec, "sample")
|
|
300
|
+
if sample is not None:
|
|
301
|
+
out[rec.accession] = sample.accession
|
|
302
|
+
return out
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _positions_for(
|
|
306
|
+
sample: _Sample,
|
|
307
|
+
assertions: Sequence[Assertion],
|
|
308
|
+
by_doc: dict[str, DocumentSubject],
|
|
309
|
+
subject_to_sample: dict[str, str],
|
|
310
|
+
) -> dict[str, list[_Position]]:
|
|
311
|
+
"""Every source's answer for every attribute of one sample. Decides nothing."""
|
|
312
|
+
out: dict[str, list[_Position]] = {}
|
|
313
|
+
|
|
314
|
+
if sample.record is not None:
|
|
315
|
+
for attr in sample.record.attributes:
|
|
316
|
+
if not attr.harmonized or attr.name in _RECORD_META or not is_attribute(attr.name):
|
|
317
|
+
continue
|
|
318
|
+
out.setdefault(attr.name, []).append(
|
|
319
|
+
_Position(
|
|
320
|
+
value=attr.value,
|
|
321
|
+
basis="asserted",
|
|
322
|
+
evidence=[sample.record.accession],
|
|
323
|
+
# A copy is not a judgement: no confidence, because none was formed. See
|
|
324
|
+
# `Evidenced.confidence`.
|
|
325
|
+
confidence=None,
|
|
326
|
+
rung=0,
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
for a in assertions:
|
|
331
|
+
if not a.field.startswith(SAMPLE_FIELD_PREFIX):
|
|
332
|
+
continue
|
|
333
|
+
name = a.field[len(SAMPLE_FIELD_PREFIX) :]
|
|
334
|
+
if not is_attribute(name):
|
|
335
|
+
continue # `fields.py` already refused it; belt and braces
|
|
336
|
+
doc = by_doc.get(a.span.doc_sha256)
|
|
337
|
+
if doc is None:
|
|
338
|
+
continue # a document code did not place has no subject, so it may not name one
|
|
339
|
+
basis = _basis_for(doc, sample, subject_to_sample)
|
|
340
|
+
if basis is None:
|
|
341
|
+
continue # this document is about a different sample
|
|
342
|
+
out.setdefault(name, []).append(
|
|
343
|
+
_Position(
|
|
344
|
+
value=a.value,
|
|
345
|
+
basis=basis,
|
|
346
|
+
evidence=[a.id],
|
|
347
|
+
confidence=a.llm_confidence,
|
|
348
|
+
rung=0,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
return out
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _basis_for(
|
|
355
|
+
doc: DocumentSubject, sample: _Sample, subject_to_sample: dict[str, str]
|
|
356
|
+
) -> Basis | None:
|
|
357
|
+
"""What a claim from this document is, *about this sample*. ``None`` = it is not about it at all.
|
|
358
|
+
|
|
359
|
+
A document that names a level *belonging to* this sample — the sample itself, or one of its
|
|
360
|
+
experiments or runs — is a declaration about that sample (``asserted``). ``subject_to_sample``
|
|
361
|
+
holds that join, computed by code from the record hierarchy, so a run alias is asserted of its
|
|
362
|
+
sample exactly as the sample's own alias is. A dataset-scoped document (a paper) makes a claim
|
|
363
|
+
about the study; that it holds of any one of six samples is **our** inference (``inferred``). That
|
|
364
|
+
distinction is what makes the precedence in :func:`_decide` principled rather than a tiebreak we
|
|
365
|
+
invented.
|
|
366
|
+
"""
|
|
367
|
+
if doc.scope == "dataset":
|
|
368
|
+
return "inferred"
|
|
369
|
+
if doc.subject is not None and subject_to_sample.get(doc.subject) == sample.accession:
|
|
370
|
+
return "asserted"
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _decide(
|
|
375
|
+
sample_id: str,
|
|
376
|
+
positions: dict[str, list[_Position]],
|
|
377
|
+
sample_scoped_attrs: frozenset[str] = frozenset(),
|
|
378
|
+
) -> tuple[dict[str, EvidencedStr], list[ValidationWarning]]:
|
|
379
|
+
"""Turn each attribute's positions into at most one value, plus non-blocking notes. Never a vote.
|
|
380
|
+
|
|
381
|
+
The resolver DECIDES here rather than defer, and either way it is resolved — so a disagreement is a
|
|
382
|
+
``warning``, not a blocking conflict:
|
|
383
|
+
|
|
384
|
+
- a stronger authority wins (``asserted`` over ``inferred``): keep its value, note the weaker
|
|
385
|
+
source that disagreed;
|
|
386
|
+
- equal authorities that disagree leave the attribute **null**, because a wrong value is permanent
|
|
387
|
+
and a missing one is not. Null is a value here, not a question for a human.
|
|
388
|
+
- a sample covered ONLY by a dataset-level ``inferred`` claim, for an attribute some *other* sample
|
|
389
|
+
owns per-sample (``sample_scoped_attrs``), is also left **null**: the paper's blanket value
|
|
390
|
+
varies by sample and is an unsafe guess for a sample the archive left blank (#10).
|
|
391
|
+
|
|
392
|
+
A null-or-precedence sample attribute must not stop a dataset compiling: the strain already tells
|
|
393
|
+
the pilot's two conditions apart, and most datasets have no such prose at all. Only the byte
|
|
394
|
+
resolver's ``observed`` vs ``asserted`` disagreement blocks — that one decides what the data *is*.
|
|
395
|
+
"""
|
|
396
|
+
attrs: dict[str, EvidencedStr] = {}
|
|
397
|
+
warnings: list[ValidationWarning] = []
|
|
398
|
+
|
|
399
|
+
for name, found in sorted(positions.items()):
|
|
400
|
+
if name in sample_scoped_attrs and all(p.basis == "inferred" for p in found):
|
|
401
|
+
# This sample has only a dataset-level (paper) claim for an attribute the archive declares
|
|
402
|
+
# per-sample elsewhere — so the attribute is sample-specific and the study-wide value is a
|
|
403
|
+
# guess here. Null beats a wrong value that a permanent, content-addressed manifest bakes
|
|
404
|
+
# in. PRJNA1027859: the paper's blanket `daf-2` must not stamp the wild-type samples.
|
|
405
|
+
seen = ", ".join(sorted(f"{p.value!r} ({p.basis})" for p in found))
|
|
406
|
+
warnings.append(
|
|
407
|
+
ValidationWarning(
|
|
408
|
+
code="sample_attribute_inferred_only",
|
|
409
|
+
message=(
|
|
410
|
+
f"{sample_id} {SAMPLE_FIELD_PREFIX}{name}: only a dataset-level inferred claim "
|
|
411
|
+
f"({seen}) covers this sample, but the attribute is declared per-sample "
|
|
412
|
+
f"elsewhere in the dataset; left null rather than stamp a study-wide value on "
|
|
413
|
+
f"one sample"
|
|
414
|
+
),
|
|
415
|
+
subject=BlockerSubject(kind="field", ref=f"{SAMPLE_FIELD_PREFIX}{name}"),
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
continue
|
|
419
|
+
distinct = {_norm_value(p.value) for p in found}
|
|
420
|
+
if len(distinct) == 1:
|
|
421
|
+
best = max(found, key=lambda p: _BASIS_RANK[p.basis])
|
|
422
|
+
attrs[name] = _evidenced(best)
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
ranked = sorted(found, key=lambda p: -_BASIS_RANK[p.basis])
|
|
426
|
+
top = _BASIS_RANK[ranked[0].basis]
|
|
427
|
+
winners = {_norm_value(p.value) for p in ranked if _BASIS_RANK[p.basis] == top}
|
|
428
|
+
seen = ", ".join(sorted(f"{p.value!r} ({p.basis})" for p in found))
|
|
429
|
+
if len(winners) == 1:
|
|
430
|
+
# a stronger authority exists: keep its value; the weaker source is only a note
|
|
431
|
+
attrs[name] = _evidenced(ranked[0])
|
|
432
|
+
resolution = f"kept the {ranked[0].basis} value {ranked[0].value!r}"
|
|
433
|
+
else:
|
|
434
|
+
# two equal authorities disagree: store nothing. A wrong value here is permanent, a
|
|
435
|
+
# missing one is not, and code does not get to break a tie between equals.
|
|
436
|
+
resolution = (
|
|
437
|
+
"left null — equal-authority sources disagree, and null beats a wrong guess"
|
|
438
|
+
)
|
|
439
|
+
warnings.append(
|
|
440
|
+
ValidationWarning(
|
|
441
|
+
code="sample_attribute_ambiguous",
|
|
442
|
+
message=f"{sample_id} {SAMPLE_FIELD_PREFIX}{name}: sources disagree ({seen}); {resolution}",
|
|
443
|
+
subject=BlockerSubject(kind="field", ref=f"{SAMPLE_FIELD_PREFIX}{name}"),
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
return attrs, warnings
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _norm_value(value: str) -> str:
|
|
451
|
+
"""Case- and whitespace-folded key for testing whether two attribute VALUES agree. 'Male' and
|
|
452
|
+
'male' are the same sex, and a permanent, content-addressed manifest must not null an
|
|
453
|
+
equal-authority attribute over a capitalization difference — only a genuine disagreement should.
|
|
454
|
+
The STORED value keeps its original case (via `_evidenced`); this folds only for the comparison.
|
|
455
|
+
"""
|
|
456
|
+
return " ".join(value.split()).casefold()
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _evidenced(p: _Position) -> EvidencedStr:
|
|
460
|
+
return EvidencedStr(
|
|
461
|
+
value=p.value,
|
|
462
|
+
basis=p.basis,
|
|
463
|
+
evidence=list(p.evidence),
|
|
464
|
+
confidence=p.confidence,
|
|
465
|
+
rung=p.rung,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _project_facts(records: ArchiveRecordSet | None) -> ProjectFacts | None:
|
|
470
|
+
"""The study's declared, structured facts. The abstract is deliberately not among them."""
|
|
471
|
+
if records is None:
|
|
472
|
+
return None
|
|
473
|
+
projects = records.at("project")
|
|
474
|
+
if not projects:
|
|
475
|
+
return None
|
|
476
|
+
p = projects[0]
|
|
477
|
+
accession = p.accession if _looks_like_accession(p.accession) else None
|
|
478
|
+
return ProjectFacts(
|
|
479
|
+
accession=accession,
|
|
480
|
+
title=p.text("study_title"),
|
|
481
|
+
center=_meta(p, "center_name"),
|
|
482
|
+
data_type=_meta(p, "data_type"),
|
|
483
|
+
released=_meta(p, "submission_date"),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _meta(record: ArchiveRecord, name: str) -> str | None:
|
|
488
|
+
for attr in record.attributes:
|
|
489
|
+
if attr.name == name:
|
|
490
|
+
return attr.value
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _organism(records: ArchiveRecordSet | None) -> EvidencedTaxid | None:
|
|
495
|
+
"""The taxid every sample record agrees on. Disagreement yields ``None`` rather than a majority.
|
|
496
|
+
|
|
497
|
+
A dataset whose samples are two organisms is a real thing (a xenograft, a co-culture), and it is
|
|
498
|
+
not something this function may flatten. ``None`` sends the caller to ask.
|
|
499
|
+
"""
|
|
500
|
+
if records is None:
|
|
501
|
+
return None
|
|
502
|
+
seen: dict[str, list[str]] = {}
|
|
503
|
+
for sample in records.at("sample"):
|
|
504
|
+
taxid = _meta(sample, "taxonomy_id")
|
|
505
|
+
if taxid:
|
|
506
|
+
seen.setdefault(taxid, []).append(sample.accession)
|
|
507
|
+
if len(seen) != 1:
|
|
508
|
+
return None
|
|
509
|
+
taxid, evidence = next(iter(seen.items()))
|
|
510
|
+
if not taxid.isdigit() or int(taxid) <= 0:
|
|
511
|
+
return None
|
|
512
|
+
return EvidencedTaxid(
|
|
513
|
+
value=int(taxid), basis="asserted", evidence=sorted(evidence), confidence=None, rung=0
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _looks_like_accession(value: str) -> bool:
|
|
518
|
+
import re
|
|
519
|
+
|
|
520
|
+
from ..models.base import Accession # noqa: F401 (documents where the pattern is owned)
|
|
521
|
+
|
|
522
|
+
return bool(re.match(r"^([SED]R[RXPS]\d+|GS[EM]\d+|PRJ[A-Z]{2}\d+|SAM[NED][A-Z]?\d+)$", value))
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _the_line() -> str:
|
|
526
|
+
"""Why this resolver is not shown the probe. Kept as prose because it is a design commitment.
|
|
527
|
+
|
|
528
|
+
The tempting version of this module reads the probe's output too — "the reads are 28+94, so the
|
|
529
|
+
protocol paragraph saying 28+94 is corroborated". Two reasons not to, and the second is the cheap
|
|
530
|
+
one:
|
|
531
|
+
|
|
532
|
+
1. A probe-sighted reader would settle ties the probe itself created, and log the wrong reason.
|
|
533
|
+
Nothing records corroboration, so the manifest would say "asserted" for a fact that a byte
|
|
534
|
+
actually decided, and the rung provenance would be a lie.
|
|
535
|
+
2. Read lengths say nothing about neurons. There is no byte in a FASTQ that bears on ``tissue``,
|
|
536
|
+
``strain``, ``sex`` or ``dev_stage``. The probe has zero bits to contribute to every field this
|
|
537
|
+
module resolves, so the whole question is moot for the fields it actually decides.
|
|
538
|
+
|
|
539
|
+
The chemistry hypothesis is the *legitimate* half of the same idea, and it goes the other way:
|
|
540
|
+
prose steers which whitelist ``score`` checks first, and never enters the evidence matrix.
|
|
541
|
+
"""
|
|
542
|
+
return __doc__ or ""
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
__all__ = [
|
|
546
|
+
"DocScope",
|
|
547
|
+
"DocumentSubject",
|
|
548
|
+
"SAMPLE_FIELD_PREFIX",
|
|
549
|
+
"resolve_metadata",
|
|
550
|
+
]
|