orfdata 0.4.0__tar.gz → 0.5.0__tar.gz

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 (61) hide show
  1. {orfdata-0.4.0/orfdata.egg-info → orfdata-0.5.0}/PKG-INFO +1 -1
  2. {orfdata-0.4.0 → orfdata-0.5.0}/docs/measurements.md +8 -1
  3. {orfdata-0.4.0 → orfdata-0.5.0}/docs/pairing.md +15 -2
  4. {orfdata-0.4.0 → orfdata-0.5.0}/docs/reference.md +2 -3
  5. orfdata-0.5.0/docs/source-references.md +90 -0
  6. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/__init__.py +13 -1
  7. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/dataset.py +116 -26
  8. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/dataset_cli.py +19 -4
  9. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/measurements.py +47 -1
  10. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/pairing.py +41 -8
  11. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/reference.py +32 -7
  12. orfdata-0.5.0/orfdata/reference_mapping.py +150 -0
  13. orfdata-0.5.0/orfdata/version.py +1 -0
  14. {orfdata-0.4.0 → orfdata-0.5.0/orfdata.egg-info}/PKG-INFO +1 -1
  15. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata.egg-info/SOURCES.txt +5 -0
  16. orfdata-0.5.0/tests/data/references/context-keys-v0.4.json +62 -0
  17. orfdata-0.5.0/tests/test_cross_reference_pairing.py +160 -0
  18. {orfdata-0.4.0 → orfdata-0.5.0}/tests/test_measurements.py +1 -1
  19. orfdata-0.5.0/tests/test_reference_mapping.py +225 -0
  20. orfdata-0.4.0/orfdata/version.py +0 -1
  21. {orfdata-0.4.0 → orfdata-0.5.0}/LICENSE +0 -0
  22. {orfdata-0.4.0 → orfdata-0.5.0}/MANIFEST.in +0 -0
  23. {orfdata-0.4.0 → orfdata-0.5.0}/README.md +0 -0
  24. {orfdata-0.4.0 → orfdata-0.5.0}/deploy.sh +0 -0
  25. {orfdata-0.4.0 → orfdata-0.5.0}/docs/design.md +0 -0
  26. {orfdata-0.4.0 → orfdata-0.5.0}/docs/species.md +0 -0
  27. {orfdata-0.4.0 → orfdata-0.5.0}/lint.sh +0 -0
  28. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/build.py +0 -0
  29. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/catalog.py +0 -0
  30. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/cli.py +0 -0
  31. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/data/datasets.json +0 -0
  32. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/data/orf-evidence/AUTHOR-LICENSE.txt +0 -0
  33. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/data/orf-evidence/NOTICE.txt +0 -0
  34. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/data/orf-evidence/catalog.json.gz +0 -0
  35. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/data/orf-evidence/manifest.json +0 -0
  36. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/data/uorf-evidence/AUTHOR-LICENSE.txt +0 -0
  37. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/data/uorf-evidence/NOTICE.txt +0 -0
  38. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/data/uorf-evidence/catalog.json.gz +0 -0
  39. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/data/uorf-evidence/manifest.json +0 -0
  40. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/datasets.py +0 -0
  41. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/initiation_context.py +0 -0
  42. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/model.py +0 -0
  43. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata/sequence.py +0 -0
  44. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata.egg-info/dependency_links.txt +0 -0
  45. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata.egg-info/entry_points.txt +0 -0
  46. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata.egg-info/requires.txt +0 -0
  47. {orfdata-0.4.0 → orfdata-0.5.0}/orfdata.egg-info/top_level.txt +0 -0
  48. {orfdata-0.4.0 → orfdata-0.5.0}/pyproject.toml +0 -0
  49. {orfdata-0.4.0 → orfdata-0.5.0}/setup.cfg +0 -0
  50. {orfdata-0.4.0 → orfdata-0.5.0}/test.sh +0 -0
  51. {orfdata-0.4.0 → orfdata-0.5.0}/tests/conftest.py +0 -0
  52. {orfdata-0.4.0 → orfdata-0.5.0}/tests/data/references/README.md +0 -0
  53. {orfdata-0.4.0 → orfdata-0.5.0}/tests/data/references/canis_lupus_familiaris.json +0 -0
  54. {orfdata-0.4.0 → orfdata-0.5.0}/tests/data/references/homo_sapiens.json +0 -0
  55. {orfdata-0.4.0 → orfdata-0.5.0}/tests/data/references/mus_musculus.json +0 -0
  56. {orfdata-0.4.0 → orfdata-0.5.0}/tests/data/references/saccharomyces_cerevisiae.json +0 -0
  57. {orfdata-0.4.0 → orfdata-0.5.0}/tests/test_initiation_context.py +0 -0
  58. {orfdata-0.4.0 → orfdata-0.5.0}/tests/test_pairing.py +0 -0
  59. {orfdata-0.4.0 → orfdata-0.5.0}/tests/test_reference_context.py +0 -0
  60. {orfdata-0.4.0 → orfdata-0.5.0}/tests/test_reference_orf.py +0 -0
  61. {orfdata-0.4.0 → orfdata-0.5.0}/tests/test_uorf_catalog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orfdata
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Reference open reading frames and experimental translation evidence on annotated transcripts
5
5
  Author: OpenVax
6
6
  License-Expression: Apache-2.0
@@ -46,6 +46,10 @@ mapping table. The default PyEnsembl target uses its unversioned `id`; request
46
46
  mapping across annotation releases, gene isoforms, ORFs or species. A variant or
47
47
  fusion product does not inherit a native reference protein measurement.
48
48
 
49
+ For native sources with unreported genomic metadata, use explicit
50
+ [source references and mapping views](source-references.md) to retain the original
51
+ observation alongside its mapping to a PyEnsembl target.
52
+
49
53
  The CLI uses the same operations:
50
54
 
51
55
  ```sh
@@ -96,7 +100,10 @@ An importer calls `QuantitativeDataset.create(path, manifest, samples=...,
96
100
  assays=..., measurements=..., mappings=...)`. These inputs may be generators;
97
101
  measurements are written incrementally. Every observation names a source asset
98
102
  or earlier input measurements. Derived inputs precede outputs in the same
99
- reference context. Duplicate IDs, broken foreign keys, wrong dataset releases
103
+ dataset release. `input_measurements` names IDs within the output reference;
104
+ `input_references` instead uses `MeasurementReference` records with each input's
105
+ dataset, version, reference and ID. The two forms cannot be mixed. Cross-species
106
+ derived abundance is rejected. Duplicate IDs, broken foreign keys, wrong dataset releases
100
107
  and cross-reference links fail the import. Failed builds do not replace data.
101
108
 
102
109
  Use `build_dataset(manifest, builder, ...)` for a managed local import.
@@ -25,6 +25,13 @@ only explicitly concurrent assays are returned; opt into `longitudinal` or
25
25
  TMT channels and bridges remain in the assays and do not become biological
26
26
  sample counts.
27
27
 
28
+ RNA annotation and protein search references can differ. `AssayPair.reference`
29
+ is the left assay's context; `right_reference` explicitly identifies the right
30
+ assay's context and defaults to the left for older records. Both must belong to
31
+ the same species. A protein FASTA does not need an invented genomic assembly:
32
+ see [source references](source-references.md). Original targets and references
33
+ remain attached to both sides, even when their local IDs happen to match.
34
+
28
35
  The CLI streams the same table rows:
29
36
 
30
37
  ```sh
@@ -39,10 +46,13 @@ and cardinality. `same_target` compares one target across assays/samples.
39
46
  `parent_rna_context` links parent RNA to an ORF but keeps the RNA value attached
40
47
  to its original gene/transcript. `shared_group_member` records an ambiguous
41
48
  protein-group assignment; the group value stays a group value.
49
+ `gene_protein_group_context` links RNA to an ambiguous source protein group
50
+ without assigning that group's abundance to an individual protein or ORF.
42
51
 
43
52
  Importers pass `AssayPair` and `TargetRelation` iterators as `pairings=` and
44
53
  `relations=` to `QuantitativeDataset.create`. IDs belong to the dataset release
45
- and reference context. Invalid assay references, contradictory known donor
54
+ and left reference context. The right assay/target keeps its own context.
55
+ Invalid assay references, contradictory known donor
46
56
  identities and conflicting one-to-one assignments fail the import. Original
47
57
  storage-version-1 datasets remain readable and have no declared relationships.
48
58
 
@@ -67,7 +77,10 @@ assays. Missing, censored or nonpositive denominators produce an unresolved
67
77
  `not_measured` result with its reason, never infinity or a measured zero. RPF/RNA
68
78
  ratios are source-defined translation-efficiency measures, not measured flux.
69
79
  All derived values retain their input IDs, pairing, target attribution and
70
- normalization assumptions.
80
+ normalization assumptions. When input reference contexts differ, the ratio
81
+ stores `MeasurementReference` records in `input_references`, rather than
82
+ unqualified local IDs. This preserves source identity across annotation and
83
+ search-FASTA releases.
71
84
 
72
85
  ## Across species
73
86
 
@@ -75,9 +75,8 @@ reconstructed RNA products and preserves each candidate's RNA support. For
75
75
  example, the native TPST1 peptide KIILSKISV is absent from the Sid TPST1–CRCP
76
76
  alternatives, so its MS evidence does not support those altered products.
77
77
 
78
- The reference integration is available in Varcode 10.12.0; the Isovar 1.50.0
79
- integration is in review. The stricter reference-context contract is coordinated
80
- with Varcode 10.13.0 and Isovar 1.51.0.
78
+ The reference integration, including strict reference-context validation, is
79
+ available in Varcode 10.13.0 and Isovar 1.51.0.
81
80
 
82
81
  ## Data and limits
83
82
 
@@ -0,0 +1,90 @@
1
+ # Source references and PyEnsembl mappings
2
+
3
+ A proteomics experiment may search a known protein FASTA without naming a
4
+ genomic assembly. An older RNA study may report its annotation provider but
5
+ omit the release. ORFdata keeps these source identities and their unknown fields.
6
+
7
+ ```python
8
+ from orfdata import ReferenceContext
9
+
10
+ source = ReferenceContext(
11
+ taxon_id=9615, species="dog", genome_build=None,
12
+ annotation_name="uniprot", annotation_version=None,
13
+ sequence_sha256=searched_fasta_sha256,
14
+ )
15
+ ```
16
+
17
+ Unknown assembly/provider fields require a pinned `sequence_sha256` or
18
+ `source_sha256`. The former identifies a sequence set; the latter can pin an
19
+ input table when its reference release is unreported. These source contexts
20
+ cannot bind a genomic transcript. A `ReferenceORF` still requires a complete
21
+ genomic model validated against its actual PyEnsembl transcript. Existing
22
+ complete-reference keys are unchanged.
23
+
24
+ ## Query through an explicit mapping
25
+
26
+ `ReferenceMapping` connects a native `ScopedTarget` to all candidate targets in
27
+ one destination reference. It records the dataset/version, mapping method,
28
+ validation, source provenance, status and unresolved/unmapped reason. Importers
29
+ must perform the stated checks against the pinned inputs. Unique identifier
30
+ resolution is separate from sequence validation.
31
+
32
+ ```python
33
+ from orfdata import ScopedTarget
34
+
35
+ target = ScopedTarget.from_pyensembl(gene, kind="gene", namespace="ensembl:110")
36
+ for view in dataset.mapped_measurements(target):
37
+ print(view.measurement.value, view.measurement.unit)
38
+ print(view.measurement.target, view.requested_target)
39
+ print(view.mapping.method, view.mapping.validation)
40
+ ```
41
+
42
+ The view contains the **original observation**, including its source reference,
43
+ target, sample, assay, unit and provenance. Nothing is relabeled or copied onto
44
+ the requested target. Direct `dataset.measurements(target=...)` still returns
45
+ only observations native to that exact target/reference.
46
+
47
+ By default, mapped queries return uniquely resolved assignments. Use
48
+ `mapping_statuses=["ambiguous", "unresolved"]` to inspect other candidates, and
49
+ `require_sequence=True` to require recorded exact sequence validation. A group
50
+ value returned for a candidate member has `attribution="group_membership_context"`;
51
+ it does not independently quantify that protein. Parent gene/transcript RNA
52
+ cannot map directly onto an ORF. Use a separate parent-context relationship.
53
+
54
+ The CLI exposes the same query:
55
+
56
+ ```sh
57
+ orfdata datasets query DATASET --mapped --reference ref:SHA256 \
58
+ --kind protein --namespace ensembl:110 --target PROTEIN_ID --require-sequence
59
+ ```
60
+
61
+ ## Importer contract
62
+
63
+ Pass `reference_mappings=` to `QuantitativeDataset.create`. Declare source and
64
+ destination contexts in the manifest. Each source/destination-reference pair
65
+ has one mapping containing all candidates, preventing competing unique claims.
66
+ Unmapped records remain visible through `dataset.reference_mappings()`.
67
+
68
+ The status is `unique`, `ambiguous`, `unmapped` or `unresolved`. A single reported
69
+ protein representative with incomplete group membership is unresolved; its
70
+ measurement cannot become a uniquely measured member. Identifier-based mappings
71
+ must say what was checked and leave sequence validation absent when unavailable.
72
+ For an exact sequence search, record:
73
+
74
+ ```python
75
+ validation = {"sequence": {
76
+ "source_sha256": source_protein_sequence_sha256,
77
+ "target_sha256": matching_protein_sequence_sha256,
78
+ "all_candidates_checked": True,
79
+ }}
80
+ ```
81
+
82
+ These are hashes of the compared sequences, distinct from the whole searched
83
+ FASTA's hash in the reference context. Both hashes must agree to count as an
84
+ exact match. ORF assignments additionally require their own `frame` and
85
+ `position` evidence. Known reference-strain differences need explicit
86
+ `strain_relationship` evidence. Cross-species mappings use the separate
87
+ [orthology API](pairing.md#across-species).
88
+
89
+ Storage version 3 preserves these mappings. Earlier versions remain readable
90
+ and report no declared reference mappings; no mapping is inferred on load.
@@ -15,7 +15,15 @@ from .catalog import (
15
15
  from .dataset import QuantitativeDataset
16
16
  from .datasets import DatasetRegistry, build_dataset
17
17
  from .initiation_context import KOZAK_PREFERENCES, InitiationContext, annotate_initiation_context
18
- from .measurements import Assay, DatasetManifest, Measurement, Sample, SourceAsset, TargetMapping
18
+ from .measurements import (
19
+ Assay,
20
+ DatasetManifest,
21
+ Measurement,
22
+ MeasurementReference,
23
+ Sample,
24
+ SourceAsset,
25
+ TargetMapping,
26
+ )
19
27
  from .model import ORFModel, ReferenceORF
20
28
  from .pairing import (
21
29
  AssayPair,
@@ -26,6 +34,7 @@ from .pairing import (
26
34
  compare_orthologs,
27
35
  )
28
36
  from .reference import ReferenceContext, ScopedTarget
37
+ from .reference_mapping import MappedMeasurement, ReferenceMapping
29
38
  from .version import __version__
30
39
 
31
40
  __all__ = [
@@ -47,6 +56,7 @@ __all__ = [
47
56
  "Sample",
48
57
  "Assay",
49
58
  "Measurement",
59
+ "MeasurementReference",
50
60
  "TargetMapping",
51
61
  "AssayPair",
52
62
  "TargetRelation",
@@ -54,6 +64,8 @@ __all__ = [
54
64
  "OrthologyMapping",
55
65
  "OrthologyComparison",
56
66
  "compare_orthologs",
67
+ "ReferenceMapping",
68
+ "MappedMeasurement",
57
69
  "load_catalog",
58
70
  "UORFCatalog",
59
71
  "UORFRecord",
@@ -13,6 +13,7 @@ from .measurements import (
13
13
  Assay,
14
14
  DatasetManifest,
15
15
  Measurement,
16
+ MeasurementReference,
16
17
  Sample,
17
18
  TargetMapping,
18
19
  )
@@ -25,6 +26,7 @@ from .pairing import (
25
26
  _check_one_to_one,
26
27
  )
27
28
  from .reference import ReferenceContext, ScopedTarget
29
+ from .reference_mapping import MappedMeasurement, ReferenceMapping
28
30
 
29
31
  _SCHEMA = """
30
32
  CREATE TABLE metadata (name TEXT PRIMARY KEY, value TEXT NOT NULL);
@@ -52,25 +54,41 @@ CREATE TABLE measurement (
52
54
  FOREIGN KEY(reference_key, assay_id, sample_id)
53
55
  REFERENCES assay(reference_key, id, sample_id));
54
56
  CREATE TABLE input_measurement (
55
- reference_key TEXT NOT NULL, output_id TEXT NOT NULL, input_id TEXT NOT NULL,
56
- PRIMARY KEY(reference_key, output_id, input_id),
57
+ reference_key TEXT NOT NULL, output_id TEXT NOT NULL,
58
+ input_reference_key TEXT NOT NULL, input_id TEXT NOT NULL,
59
+ PRIMARY KEY(reference_key, output_id, input_reference_key, input_id),
57
60
  FOREIGN KEY(reference_key, output_id) REFERENCES measurement(reference_key, id),
58
- FOREIGN KEY(reference_key, input_id) REFERENCES measurement(reference_key, id));
61
+ FOREIGN KEY(input_reference_key, input_id) REFERENCES measurement(reference_key, id));
59
62
  CREATE INDEX target_lookup ON target(reference_key, namespace, kind, identifier);
60
63
  CREATE INDEX observation_lookup ON measurement(reference_key, target_key, sample_id, assay_id);
61
64
  CREATE INDEX assay_lookup ON measurement(reference_key, assay_id, quantity, status);
62
65
  CREATE TABLE assay_pair (
63
66
  reference_key TEXT NOT NULL REFERENCES reference(key), id TEXT NOT NULL,
67
+ right_reference_key TEXT NOT NULL REFERENCES reference(key),
64
68
  left_assay_id TEXT, right_assay_id TEXT, record TEXT NOT NULL,
65
69
  PRIMARY KEY(reference_key, id),
66
70
  FOREIGN KEY(reference_key, left_assay_id) REFERENCES assay(reference_key, id),
67
- FOREIGN KEY(reference_key, right_assay_id) REFERENCES assay(reference_key, id));
71
+ FOREIGN KEY(right_reference_key, right_assay_id) REFERENCES assay(reference_key, id));
68
72
  CREATE TABLE target_relation (
69
73
  reference_key TEXT NOT NULL, id TEXT NOT NULL,
74
+ right_reference_key TEXT NOT NULL,
70
75
  left_target_key TEXT NOT NULL, right_target_key TEXT NOT NULL, record TEXT NOT NULL,
71
76
  PRIMARY KEY(reference_key, id),
72
77
  FOREIGN KEY(reference_key, left_target_key) REFERENCES target(reference_key, key),
73
- FOREIGN KEY(reference_key, right_target_key) REFERENCES target(reference_key, key));
78
+ FOREIGN KEY(right_reference_key, right_target_key) REFERENCES target(reference_key, key));
79
+ CREATE TABLE reference_mapping (
80
+ reference_key TEXT NOT NULL, id TEXT NOT NULL, source_target_key TEXT NOT NULL,
81
+ target_reference_key TEXT NOT NULL REFERENCES reference(key), record TEXT NOT NULL,
82
+ PRIMARY KEY(reference_key, id),
83
+ UNIQUE(reference_key, source_target_key, target_reference_key),
84
+ FOREIGN KEY(reference_key, source_target_key) REFERENCES target(reference_key, key));
85
+ CREATE TABLE reference_assignment (
86
+ source_reference_key TEXT NOT NULL, mapping_id TEXT NOT NULL,
87
+ target_reference_key TEXT NOT NULL, target_key TEXT NOT NULL,
88
+ PRIMARY KEY(source_reference_key, mapping_id, target_reference_key, target_key),
89
+ FOREIGN KEY(source_reference_key, mapping_id) REFERENCES reference_mapping(reference_key, id),
90
+ FOREIGN KEY(target_reference_key, target_key) REFERENCES target(reference_key, key));
91
+ CREATE INDEX reference_assignment_lookup ON reference_assignment(target_reference_key, target_key);
74
92
  """
75
93
 
76
94
 
@@ -94,7 +112,7 @@ class QuantitativeDataset:
94
112
  self.path = Path(path).resolve()
95
113
  with closing(self._connect()) as connection:
96
114
  self._storage_version = connection.execute("PRAGMA user_version").fetchone()[0]
97
- if self._storage_version not in (1, 2):
115
+ if self._storage_version not in (1, 2, 3):
98
116
  raise ValueError("Unsupported quantitative database schema")
99
117
  row = connection.execute("SELECT value FROM metadata WHERE name='manifest'").fetchone()
100
118
  if row is None:
@@ -110,7 +128,7 @@ class QuantitativeDataset:
110
128
 
111
129
  @classmethod
112
130
  def create(cls, path, manifest, *, samples, assays, measurements, mappings=(),
113
- pairings=(), relations=()):
131
+ pairings=(), relations=(), reference_mappings=()):
114
132
  """Write a new dataset from iterators, validating scope and foreign keys.
115
133
 
116
134
  Never overwrite an existing file. An interrupted/invalid build removes
@@ -128,7 +146,7 @@ class QuantitativeDataset:
128
146
  connection = sqlite3.connect(path)
129
147
  connection.execute("PRAGMA foreign_keys=ON")
130
148
  connection.executescript(_SCHEMA)
131
- connection.execute("PRAGMA user_version=2")
149
+ connection.execute("PRAGMA user_version=3")
132
150
  connection.execute("INSERT INTO metadata VALUES ('manifest', ?)",
133
151
  (_json(manifest.to_dict()),))
134
152
  for reference in manifest.references:
@@ -154,19 +172,20 @@ class QuantitativeDataset:
154
172
  raise ValueError("Pairing belongs to a different dataset release")
155
173
  if pair.basis in ("same_material", "adjacent_donor_tissue"):
156
174
  units = []
157
- for identifier in (pair.left_assay_id, pair.right_assay_id):
175
+ for reference, identifier in ((pair.reference, pair.left_assay_id),
176
+ (pair.right_reference, pair.right_assay_id)):
158
177
  row = connection.execute(
159
178
  "SELECT s.record FROM assay a JOIN sample s ON a.reference_key=s.reference_key "
160
179
  "AND a.sample_id=s.id WHERE a.reference_key=? AND a.id=?",
161
- (pair.reference.key, identifier)).fetchone()
180
+ (reference.key, identifier)).fetchone()
162
181
  if row:
163
182
  unit = json.loads(row[0]).get("biological_unit_id")
164
183
  if unit is not None:
165
184
  units.append(unit)
166
185
  if len(set(units)) > 1:
167
186
  raise ValueError("Declared pairing contradicts biological-unit identities")
168
- connection.execute("INSERT INTO assay_pair VALUES (?, ?, ?, ?, ?)",
169
- (pair.reference.key, pair.pair_id, pair.left_assay_id,
187
+ connection.execute("INSERT INTO assay_pair VALUES (?, ?, ?, ?, ?, ?)",
188
+ (pair.reference.key, pair.pair_id, pair.right_reference.key, pair.left_assay_id,
170
189
  pair.right_assay_id, _json(pair.to_dict())))
171
190
  relation_links = ({}, {})
172
191
  for relation in relations:
@@ -180,10 +199,11 @@ class QuantitativeDataset:
180
199
  target.namespace, target.identifier))
181
200
  for index, (first, second) in enumerate(((relation.left, relation.right), (relation.right, relation.left))):
182
201
  _check_one_to_one(relation_links[index],
183
- (relation.relation, first.key, second.kind, second.namespace), second.key,
202
+ (relation.relation, first.key, second.reference.key, second.kind, second.namespace), second.key,
184
203
  relation.cardinality == "one_to_one")
185
- connection.execute("INSERT INTO target_relation VALUES (?, ?, ?, ?, ?)",
204
+ connection.execute("INSERT INTO target_relation VALUES (?, ?, ?, ?, ?, ?)",
186
205
  (relation.left.reference.key, relation.relation_id,
206
+ relation.right.reference.key,
187
207
  _target_key(relation.left), _target_key(relation.right),
188
208
  _json(relation.to_dict())))
189
209
  for mapping in mappings:
@@ -192,6 +212,21 @@ class QuantitativeDataset:
192
212
  connection.execute("INSERT INTO mapping VALUES (?, ?, ?)",
193
213
  (mapping.reference.key, mapping.mapping_id,
194
214
  _json(mapping.to_dict())))
215
+ for mapping in reference_mappings:
216
+ if not isinstance(mapping, ReferenceMapping):
217
+ raise TypeError("reference_mappings must contain ReferenceMappings")
218
+ if (mapping.dataset_id, mapping.dataset_version) != (manifest.dataset_id, manifest.version):
219
+ raise ValueError("Reference mapping belongs to a different dataset release")
220
+ for target in (mapping.source, *mapping.targets):
221
+ connection.execute("INSERT OR IGNORE INTO target VALUES (?, ?, ?, ?, ?)",
222
+ (target.reference.key, _target_key(target), target.kind,
223
+ target.namespace, target.identifier))
224
+ connection.execute("INSERT INTO reference_mapping VALUES (?, ?, ?, ?, ?)",
225
+ (mapping.source.reference.key, mapping.mapping_id, _target_key(mapping.source),
226
+ mapping.target_reference.key, _json(mapping.to_dict())))
227
+ connection.executemany("INSERT INTO reference_assignment VALUES (?, ?, ?, ?)",
228
+ ((mapping.source.reference.key, mapping.mapping_id,
229
+ target.reference.key, _target_key(target)) for target in mapping.targets))
195
230
  source_ids = {s.asset_id for s in manifest.sources}
196
231
  for observation in measurements:
197
232
  if not isinstance(observation, Measurement):
@@ -201,15 +236,17 @@ class QuantitativeDataset:
201
236
  source = observation.provenance.get("source_asset")
202
237
  if source is not None and source not in source_ids:
203
238
  raise ValueError("Measurement provenance names an unknown source asset")
204
- if source is None and not observation.input_measurements:
239
+ if source is None and not (observation.input_measurements or observation.input_references):
205
240
  raise ValueError("Measurement requires a source_asset or input measurements")
206
241
  reference_key = observation.target.reference.key
207
- for identifier in observation.input_measurements:
242
+ inputs = [(reference_key, identifier) for identifier in observation.input_measurements]
243
+ inputs.extend((value.reference.key, value.measurement_id) for value in observation.input_references)
244
+ for input_reference, identifier in inputs:
208
245
  if connection.execute(
209
246
  "SELECT 1 FROM measurement WHERE reference_key=? AND id=?",
210
- (reference_key, identifier),
247
+ (input_reference, identifier),
211
248
  ).fetchone() is None:
212
- raise ValueError("Derived inputs must precede outputs in the same reference context")
249
+ raise ValueError("Derived inputs must precede outputs in their declared reference contexts")
213
250
  target = observation.target
214
251
  target_key = _target_key(target)
215
252
  connection.execute("INSERT OR IGNORE INTO target VALUES (?, ?, ?, ?, ?)",
@@ -221,12 +258,12 @@ class QuantitativeDataset:
221
258
  (reference_key, observation.measurement_id, target_key,
222
259
  observation.sample_id, observation.assay_id,
223
260
  observation.quantity, observation.status, _json(record)))
224
- connection.executemany("INSERT INTO input_measurement VALUES (?, ?, ?)",
225
- ((reference_key, observation.measurement_id, identifier)
226
- for identifier in observation.input_measurements))
261
+ connection.executemany("INSERT INTO input_measurement VALUES (?, ?, ?, ?)",
262
+ ((reference_key, observation.measurement_id, input_reference, identifier)
263
+ for input_reference, identifier in inputs))
227
264
  counts = {table: connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
228
265
  for table in ("sample", "assay", "mapping", "target", "measurement",
229
- "assay_pair", "target_relation")}
266
+ "assay_pair", "target_relation", "reference_mapping")}
230
267
  connection.execute("INSERT INTO metadata VALUES ('counts', ?)", (_json(counts),))
231
268
  connection.commit()
232
269
  connection.close()
@@ -267,6 +304,54 @@ class QuantitativeDataset:
267
304
  def mappings(self, *, reference=None):
268
305
  return self._records("mapping", TargetMapping, reference=reference)
269
306
 
307
+ def reference_mappings(self, *, source_reference=None, target=None):
308
+ """Iterate original source-to-reference mappings, including unresolved IDs."""
309
+ scope = self._scope(source_reference)
310
+ if target is not None:
311
+ if not isinstance(target, ScopedTarget):
312
+ raise TypeError("target must be a ScopedTarget")
313
+ self._scope(target.reference)
314
+ if self._storage_version < 3:
315
+ return
316
+ query = "SELECT m.record FROM reference_mapping m"
317
+ conditions, params = [], []
318
+ if target is not None:
319
+ query += (" JOIN reference_assignment a ON m.reference_key=a.source_reference_key "
320
+ "AND m.id=a.mapping_id")
321
+ conditions.extend(("a.target_reference_key=?", "a.target_key=?"))
322
+ params.extend((target.reference.key, _target_key(target)))
323
+ if scope is not None:
324
+ conditions.append("m.reference_key=?")
325
+ params.append(scope)
326
+ if conditions:
327
+ query += " WHERE " + " AND ".join(conditions)
328
+ query += " ORDER BY m.reference_key, m.id"
329
+ with closing(self._connect()) as connection:
330
+ for row in connection.execute(query, params):
331
+ yield ReferenceMapping.from_dict(json.loads(row[0]))
332
+
333
+ def mapped_measurements(self, target, *, mapping_statuses=("unique",),
334
+ require_sequence=False, **observation_filters):
335
+ """Return original values plus mappings, never relabeled measurements.
336
+
337
+ Ambiguous or unresolved candidate membership requires explicit status
338
+ opt-in. Gene RNA cannot map to ORF abundance. require_sequence excludes
339
+ identifier-only mappings; ORF assignments always require their own
340
+ sequence/frame/position validation when the mapping is constructed.
341
+ """
342
+ allowed = set(mapping_statuses)
343
+ if not allowed or allowed - {"unique", "ambiguous", "unresolved"}:
344
+ raise ValueError("Choose explicit usable mapping statuses")
345
+ if type(require_sequence) is not bool:
346
+ raise TypeError("require_sequence must be a boolean")
347
+ if set(observation_filters) - {"sample_id", "assay_id", "quantity", "status"}:
348
+ raise ValueError("Mapped observation filters cannot override source/target reference identity")
349
+ for mapping in self.reference_mappings(target=target):
350
+ if mapping.status not in allowed or (require_sequence and not mapping.sequence_validated):
351
+ continue
352
+ for observation in self.measurements(target=mapping.source, **observation_filters):
353
+ yield MappedMeasurement(observation, mapping, target)
354
+
270
355
  def pairings(self, *, reference=None):
271
356
  """Iterate source-declared pairings; legacy releases have none."""
272
357
  if self._storage_version == 1:
@@ -275,7 +360,7 @@ class QuantitativeDataset:
275
360
  return self._records("assay_pair", AssayPair, reference=reference)
276
361
 
277
362
  def relations(self, *, reference=None):
278
- """Iterate source-backed within-reference target relationships."""
363
+ """Iterate same-species relationships, optionally scoped by their left reference."""
279
364
  if self._storage_version == 1:
280
365
  self._scope(reference)
281
366
  return iter(())
@@ -307,14 +392,16 @@ class QuantitativeDataset:
307
392
  if pair_ids is not None and pair.pair_id not in pair_ids:
308
393
  continue
309
394
  by_target = []
310
- for assay in (pair.left_assay_id, pair.right_assay_id):
395
+ for assay_reference, assay in ((pair.reference, pair.left_assay_id),
396
+ (pair.right_reference, pair.right_assay_id)):
311
397
  values = {}
312
398
  if assay is not None:
313
- for value in self.measurements(reference=pair.reference, assay_id=assay):
399
+ for value in self.measurements(reference=assay_reference, assay_id=assay):
314
400
  values.setdefault(value.target.key, []).append(value)
315
401
  by_target.append(values)
316
402
  for relation in relations:
317
- if relation.left.reference.key == pair.reference.key:
403
+ if (relation.left.reference.key == pair.reference.key
404
+ and relation.right.reference.key == pair.right_reference.key):
318
405
  yield MatchedMeasurements(pair, relation,
319
406
  tuple(by_target[0].get(relation.left.key, ())),
320
407
  tuple(by_target[1].get(relation.right.key, ())))
@@ -353,6 +440,9 @@ class QuantitativeDataset:
353
440
  for row in connection.execute(query, [value for _, value in selected]):
354
441
  values = json.loads(row[0])
355
442
  values["target"] = ScopedTarget(self._references[row[1]], row[2], row[4], row[3])
443
+ if "input_references" in values:
444
+ values["input_references"] = tuple(MeasurementReference.from_dict(value)
445
+ for value in values["input_references"])
356
446
  yield Measurement(**values)
357
447
 
358
448
  @property
@@ -36,6 +36,12 @@ def run(argv=None, prog=None):
36
36
  subparser.add_argument("--target")
37
37
  subparser.add_argument("--kind")
38
38
  subparser.add_argument("--namespace")
39
+ subparser.add_argument("--mapped", action="store_true",
40
+ help="Return source observations with explicit reference mappings")
41
+ subparser.add_argument("--mapping-status", action="append",
42
+ help="Mapping status to include; default unique")
43
+ subparser.add_argument("--require-sequence", action="store_true",
44
+ help="Require recorded exact sequence validation for mapped queries")
39
45
  if command == "pairs":
40
46
  subparser.add_argument("--basis", action="append", required=True,
41
47
  help="Allowed pairing basis; repeat for multiple bases")
@@ -84,10 +90,19 @@ def run(argv=None, prog=None):
84
90
  parser.error("Target queries require --target, --kind, --namespace and --reference")
85
91
  references = {r.key: r for r in dataset.manifest.references}
86
92
  target = ScopedTarget(references[args.reference], args.kind, args.target, args.namespace)
87
- for measurement in dataset.measurements(
88
- target=target, reference=args.reference, sample_id=args.sample,
89
- assay_id=args.assay_id, quantity=args.quantity, status=args.status,
90
- ):
93
+ filters = dict(sample_id=args.sample, assay_id=args.assay_id,
94
+ quantity=args.quantity, status=args.status)
95
+ if args.mapped:
96
+ if target is None:
97
+ parser.error("--mapped requires an exact target and reference")
98
+ measurements = dataset.mapped_measurements(
99
+ target, mapping_statuses=args.mapping_status or ["unique"],
100
+ require_sequence=args.require_sequence, **filters)
101
+ else:
102
+ if args.mapping_status or args.require_sequence:
103
+ parser.error("Mapping filters require --mapped")
104
+ measurements = dataset.measurements(target=target, reference=args.reference, **filters)
105
+ for measurement in measurements:
91
106
  sys.stdout.write(json.dumps(measurement.to_dict()) + "\n")
92
107
  return
93
108
  json.dump(result, sys.stdout, indent=2)
@@ -91,6 +91,9 @@ class _Record:
91
91
  row["reference"] = ReferenceContext(**row["reference"])
92
92
  targets.append(ScopedTarget(**row))
93
93
  values["targets"] = tuple(targets)
94
+ if "input_references" in values:
95
+ values["input_references"] = tuple(MeasurementReference.from_dict(row)
96
+ for row in values["input_references"])
94
97
  return cls(**values)
95
98
 
96
99
 
@@ -246,6 +249,33 @@ class Assay(_Record):
246
249
  _metadata(self, "normalization", "calibration", "attributes", "provenance")
247
250
 
248
251
 
252
+ @dataclass(frozen=True)
253
+ class MeasurementReference(_Record):
254
+ """An input observation identified across dataset releases and references."""
255
+
256
+ dataset_id: str
257
+ dataset_version: str
258
+ reference: ReferenceContext
259
+ measurement_id: str
260
+
261
+ def __post_init__(self):
262
+ for name in ("dataset_id", "dataset_version", "measurement_id"):
263
+ _identifier(getattr(self, name), name)
264
+ if not isinstance(self.reference, ReferenceContext):
265
+ raise TypeError("Measurement reference requires a ReferenceContext")
266
+
267
+ @property
268
+ def key(self):
269
+ return self.dataset_id, self.dataset_version, self.reference.key, self.measurement_id
270
+
271
+ @classmethod
272
+ def from_measurement(cls, measurement):
273
+ if not isinstance(measurement, Measurement):
274
+ raise TypeError("Expected a Measurement")
275
+ return cls(measurement.dataset_id, measurement.dataset_version,
276
+ measurement.target.reference, measurement.measurement_id)
277
+
278
+
249
279
  @dataclass(frozen=True)
250
280
  class Measurement(_Record):
251
281
  """A native value with units, missingness, target resolution and provenance.
@@ -275,6 +305,7 @@ class Measurement(_Record):
275
305
  input_measurements: tuple = ()
276
306
  derivation: Mapping = field(default_factory=dict)
277
307
  provenance: Mapping = field(default_factory=dict)
308
+ input_references: tuple = ()
278
309
 
279
310
  def __post_init__(self):
280
311
  for name in ("measurement_id", "sample_id", "assay_id", "unit", "dataset_id", "dataset_version"):
@@ -314,11 +345,26 @@ class Measurement(_Record):
314
345
  if len(set(inputs)) != len(inputs) or self.measurement_id in inputs:
315
346
  raise ValueError("Derived input IDs must be unique and exclude the output")
316
347
  object.__setattr__(self, "input_measurements", inputs)
348
+ references = tuple(self.input_references)
349
+ if inputs and references:
350
+ raise ValueError("Choose legacy same-context input IDs or explicit input references, not both")
351
+ if any(not isinstance(value, MeasurementReference) for value in references):
352
+ raise TypeError("input_references must contain MeasurementReferences")
353
+ own_key = (self.dataset_id, self.dataset_version, self.target.reference.key, self.measurement_id)
354
+ keys = {value.key for value in references}
355
+ if len(keys) != len(references) or own_key in keys:
356
+ raise ValueError("Input references must be unique and exclude the output")
357
+ for value in references:
358
+ if (value.dataset_id, value.dataset_version) != (self.dataset_id, self.dataset_version):
359
+ raise ValueError("Derived inputs must belong to the same dataset release")
360
+ if value.reference.taxon_id != self.target.reference.taxon_id:
361
+ raise ValueError("Derived abundance inputs cannot cross species")
362
+ object.__setattr__(self, "input_references", references)
317
363
  if self.origin in ("derived", "predicted") and not self.derivation:
318
364
  raise ValueError("Derived/predicted values require a named method and assumptions")
319
365
  if self.origin in ("derived", "predicted") and isinstance(self.derivation, Mapping):
320
366
  _identifier(self.derivation.get("method"), "derivation method")
321
- if self.origin == "observed" and (inputs or self.derivation):
367
+ if self.origin == "observed" and (inputs or references or self.derivation):
322
368
  raise ValueError("An observed value cannot contain a derivation")
323
369
  _metadata(self, "derivation", "provenance")
324
370