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,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
+ ]