orfdata 0.3.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.3.0/orfdata.egg-info → orfdata-0.5.0}/PKG-INFO +1 -1
  2. {orfdata-0.3.0 → orfdata-0.5.0}/docs/measurements.md +11 -4
  3. orfdata-0.5.0/docs/pairing.md +97 -0
  4. {orfdata-0.3.0 → orfdata-0.5.0}/docs/reference.md +2 -3
  5. orfdata-0.5.0/docs/source-references.md +90 -0
  6. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/__init__.py +27 -1
  7. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/dataset.py +218 -14
  8. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/dataset_cli.py +36 -5
  9. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/measurements.py +48 -1
  10. orfdata-0.5.0/orfdata/pairing.py +362 -0
  11. {orfdata-0.3.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.3.0 → orfdata-0.5.0/orfdata.egg-info}/PKG-INFO +1 -1
  15. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata.egg-info/SOURCES.txt +9 -0
  16. orfdata-0.5.0/tests/conftest.py +45 -0
  17. orfdata-0.5.0/tests/data/references/context-keys-v0.4.json +62 -0
  18. orfdata-0.5.0/tests/test_cross_reference_pairing.py +160 -0
  19. {orfdata-0.3.0 → orfdata-0.5.0}/tests/test_measurements.py +2 -33
  20. orfdata-0.5.0/tests/test_pairing.py +269 -0
  21. orfdata-0.5.0/tests/test_reference_mapping.py +225 -0
  22. orfdata-0.3.0/orfdata/version.py +0 -1
  23. {orfdata-0.3.0 → orfdata-0.5.0}/LICENSE +0 -0
  24. {orfdata-0.3.0 → orfdata-0.5.0}/MANIFEST.in +0 -0
  25. {orfdata-0.3.0 → orfdata-0.5.0}/README.md +0 -0
  26. {orfdata-0.3.0 → orfdata-0.5.0}/deploy.sh +0 -0
  27. {orfdata-0.3.0 → orfdata-0.5.0}/docs/design.md +0 -0
  28. {orfdata-0.3.0 → orfdata-0.5.0}/docs/species.md +0 -0
  29. {orfdata-0.3.0 → orfdata-0.5.0}/lint.sh +0 -0
  30. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/build.py +0 -0
  31. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/catalog.py +0 -0
  32. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/cli.py +0 -0
  33. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/data/datasets.json +0 -0
  34. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/data/orf-evidence/AUTHOR-LICENSE.txt +0 -0
  35. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/data/orf-evidence/NOTICE.txt +0 -0
  36. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/data/orf-evidence/catalog.json.gz +0 -0
  37. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/data/orf-evidence/manifest.json +0 -0
  38. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/data/uorf-evidence/AUTHOR-LICENSE.txt +0 -0
  39. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/data/uorf-evidence/NOTICE.txt +0 -0
  40. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/data/uorf-evidence/catalog.json.gz +0 -0
  41. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/data/uorf-evidence/manifest.json +0 -0
  42. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/datasets.py +0 -0
  43. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/initiation_context.py +0 -0
  44. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/model.py +0 -0
  45. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata/sequence.py +0 -0
  46. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata.egg-info/dependency_links.txt +0 -0
  47. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata.egg-info/entry_points.txt +0 -0
  48. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata.egg-info/requires.txt +0 -0
  49. {orfdata-0.3.0 → orfdata-0.5.0}/orfdata.egg-info/top_level.txt +0 -0
  50. {orfdata-0.3.0 → orfdata-0.5.0}/pyproject.toml +0 -0
  51. {orfdata-0.3.0 → orfdata-0.5.0}/setup.cfg +0 -0
  52. {orfdata-0.3.0 → orfdata-0.5.0}/test.sh +0 -0
  53. {orfdata-0.3.0 → orfdata-0.5.0}/tests/data/references/README.md +0 -0
  54. {orfdata-0.3.0 → orfdata-0.5.0}/tests/data/references/canis_lupus_familiaris.json +0 -0
  55. {orfdata-0.3.0 → orfdata-0.5.0}/tests/data/references/homo_sapiens.json +0 -0
  56. {orfdata-0.3.0 → orfdata-0.5.0}/tests/data/references/mus_musculus.json +0 -0
  57. {orfdata-0.3.0 → orfdata-0.5.0}/tests/data/references/saccharomyces_cerevisiae.json +0 -0
  58. {orfdata-0.3.0 → orfdata-0.5.0}/tests/test_initiation_context.py +0 -0
  59. {orfdata-0.3.0 → orfdata-0.5.0}/tests/test_reference_context.py +0 -0
  60. {orfdata-0.3.0 → orfdata-0.5.0}/tests/test_reference_orf.py +0 -0
  61. {orfdata-0.3.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.3.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
@@ -80,9 +84,9 @@ records technical replicates, runs, fractions, TMT plex/channel/bridge,
80
84
  normalization and calibration. A sample's strain/breed belongs in its attributes;
81
85
  it need not be the strain used to assemble its reference genome. `TargetMapping`
82
86
  retains all candidate assignments or an unmapped reason. A protein-group
83
- representative does not become a uniquely measured protein. Explicit sample
84
- pairing and orthology comparisons follow in
85
- [issue #6](https://github.com/openvax/orfdata/issues/6).
87
+ representative does not become a uniquely measured protein. See
88
+ [pairing and orthology comparisons](pairing.md) for explicit experimental
89
+ relationships and derived RNA/protein ratios.
86
90
 
87
91
  ## Importing a source
88
92
 
@@ -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.
@@ -0,0 +1,97 @@
1
+ # Comparing RNA, ribosome and protein abundance
2
+
3
+ Use declared experimental relationships to compare assays. A tissue label alone
4
+ does not make an RNA sample and a protein sample a matched pair.
5
+
6
+ ```python
7
+ for match in dataset.matched_measurements(allowed_bases=["same_material"]):
8
+ row = match.to_table_row()
9
+ print(row["rna_abundance"], row["protein_abundance"])
10
+ ```
11
+
12
+ Rows include named RNA, ribosome occupancy, translation-efficiency, protein,
13
+ peptide and spectrum-count columns, each containing original measurement
14
+ records with units, missingness and provenance. Missing columns are empty lists.
15
+ Multiple values remain multiple values; the API does not average them or create
16
+ a Cartesian product of technical replicates. Each row retains its assay pairing
17
+ and target relationship. A three-assay experiment can declare several pairwise
18
+ comparisons; sharing an RNA measurement does not create independent replicates.
19
+
20
+ Pairing bases are `same_material`, `adjacent_donor_tissue`,
21
+ `biological_replicate`, `independent_cultures`, `tissue_aggregate` and
22
+ `condition_aggregate`. The caller must choose the allowed bases. By default,
23
+ only explicitly concurrent assays are returned; opt into `longitudinal` or
24
+ `unknown` with `allowed_temporal`. Missing mates stay explicit. Technical runs,
25
+ TMT channels and bridges remain in the assays and do not become biological
26
+ sample counts.
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
+
35
+ The CLI streams the same table rows:
36
+
37
+ ```sh
38
+ orfdata datasets pairs DATASET --version RELEASE --basis same_material
39
+ ```
40
+
41
+ ## Which targets are being compared?
42
+
43
+ `TargetRelation` links exact, reference-scoped targets with mapping evidence
44
+ and cardinality. `same_target` compares one target across assays/samples.
45
+ `canonical_translation` links a gene/transcript to its annotated protein.
46
+ `parent_rna_context` links parent RNA to an ORF but keeps the RNA value attached
47
+ to its original gene/transcript. `shared_group_member` records an ambiguous
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.
51
+
52
+ Importers pass `AssayPair` and `TargetRelation` iterators as `pairings=` and
53
+ `relations=` to `QuantitativeDataset.create`. IDs belong to the dataset release
54
+ and left reference context. The right assay/target keeps its own context.
55
+ Invalid assay references, contradictory known donor
56
+ identities and conflicting one-to-one assignments fail the import. Original
57
+ storage-version-1 datasets remain readable and have no declared relationships.
58
+
59
+ ## Ratios are derived measurements
60
+
61
+ ```python
62
+ ratio = match.ratio(
63
+ selected_protein_measurement, selected_rna_measurement,
64
+ measurement_id="ratio-1", method="native protein/RNA ratio",
65
+ normalization={"protein": "source bridge normalization", "rna": "source TPM"},
66
+ )
67
+ ```
68
+
69
+ Select actual measurements from opposite sides of the match. Ratios require
70
+ one-to-one target attribution and linear inputs. Parent-RNA/ORF and shared-group
71
+ links cannot produce target-specific ratios. There is no implicit log conversion,
72
+ unit conversion, replicate averaging or uncertainty propagation.
73
+
74
+ An iBAQ/TPM ratio keeps `(iBAQ)/(TPM)` units. Copies-per-cell values also retain
75
+ their native composite units and the calibration recorded on their source
76
+ assays. Missing, censored or nonpositive denominators produce an unresolved
77
+ `not_measured` result with its reason, never infinity or a measured zero. RPF/RNA
78
+ ratios are source-defined translation-efficiency measures, not measured flux.
79
+ All derived values retain their input IDs, pairing, target attribution and
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.
84
+
85
+ ## Across species
86
+
87
+ `compare_orthologs(left_dataset, right_dataset, mappings)` is separate and
88
+ opt-in. Each `OrthologyMapping` pins its source file, release, both reference
89
+ contexts, cardinality, confidence definition and evidence. The default accepts
90
+ one-to-one canonical gene mappings; one-to-many relationships require explicit
91
+ permission through `allowed_cardinalities`.
92
+
93
+ The result contains each species' original observations. It neither merges
94
+ identities nor transfers abundance or protein-existence evidence. ORF comparisons
95
+ additionally require their own sequence, frame and positional evidence and
96
+ `allow_orfs=True`; a gene ortholog cannot establish an orthologous ncORF.
97
+ Human, mouse, dog, yeast and arbitrary other taxa use this same API.
@@ -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,9 +15,26 @@ 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
28
+ from .pairing import (
29
+ AssayPair,
30
+ MatchedMeasurements,
31
+ OrthologyComparison,
32
+ OrthologyMapping,
33
+ TargetRelation,
34
+ compare_orthologs,
35
+ )
20
36
  from .reference import ReferenceContext, ScopedTarget
37
+ from .reference_mapping import MappedMeasurement, ReferenceMapping
21
38
  from .version import __version__
22
39
 
23
40
  __all__ = [
@@ -39,7 +56,16 @@ __all__ = [
39
56
  "Sample",
40
57
  "Assay",
41
58
  "Measurement",
59
+ "MeasurementReference",
42
60
  "TargetMapping",
61
+ "AssayPair",
62
+ "TargetRelation",
63
+ "MatchedMeasurements",
64
+ "OrthologyMapping",
65
+ "OrthologyComparison",
66
+ "compare_orthologs",
67
+ "ReferenceMapping",
68
+ "MappedMeasurement",
43
69
  "load_catalog",
44
70
  "UORFCatalog",
45
71
  "UORFRecord",
@@ -13,10 +13,20 @@ from .measurements import (
13
13
  Assay,
14
14
  DatasetManifest,
15
15
  Measurement,
16
+ MeasurementReference,
16
17
  Sample,
17
18
  TargetMapping,
18
19
  )
20
+ from .pairing import (
21
+ PAIRING_BASES,
22
+ TEMPORAL_RELATIONS,
23
+ AssayPair,
24
+ MatchedMeasurements,
25
+ TargetRelation,
26
+ _check_one_to_one,
27
+ )
19
28
  from .reference import ReferenceContext, ScopedTarget
29
+ from .reference_mapping import MappedMeasurement, ReferenceMapping
20
30
 
21
31
  _SCHEMA = """
22
32
  CREATE TABLE metadata (name TEXT PRIMARY KEY, value TEXT NOT NULL);
@@ -44,13 +54,41 @@ CREATE TABLE measurement (
44
54
  FOREIGN KEY(reference_key, assay_id, sample_id)
45
55
  REFERENCES assay(reference_key, id, sample_id));
46
56
  CREATE TABLE input_measurement (
47
- reference_key TEXT NOT NULL, output_id TEXT NOT NULL, input_id TEXT NOT NULL,
48
- 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),
49
60
  FOREIGN KEY(reference_key, output_id) REFERENCES measurement(reference_key, id),
50
- FOREIGN KEY(reference_key, input_id) REFERENCES measurement(reference_key, id));
61
+ FOREIGN KEY(input_reference_key, input_id) REFERENCES measurement(reference_key, id));
51
62
  CREATE INDEX target_lookup ON target(reference_key, namespace, kind, identifier);
52
63
  CREATE INDEX observation_lookup ON measurement(reference_key, target_key, sample_id, assay_id);
53
64
  CREATE INDEX assay_lookup ON measurement(reference_key, assay_id, quantity, status);
65
+ CREATE TABLE assay_pair (
66
+ reference_key TEXT NOT NULL REFERENCES reference(key), id TEXT NOT NULL,
67
+ right_reference_key TEXT NOT NULL REFERENCES reference(key),
68
+ left_assay_id TEXT, right_assay_id TEXT, record TEXT NOT NULL,
69
+ PRIMARY KEY(reference_key, id),
70
+ FOREIGN KEY(reference_key, left_assay_id) REFERENCES assay(reference_key, id),
71
+ FOREIGN KEY(right_reference_key, right_assay_id) REFERENCES assay(reference_key, id));
72
+ CREATE TABLE target_relation (
73
+ reference_key TEXT NOT NULL, id TEXT NOT NULL,
74
+ right_reference_key TEXT NOT NULL,
75
+ left_target_key TEXT NOT NULL, right_target_key TEXT NOT NULL, record TEXT NOT NULL,
76
+ PRIMARY KEY(reference_key, id),
77
+ FOREIGN KEY(reference_key, left_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);
54
92
  """
55
93
 
56
94
 
@@ -73,7 +111,8 @@ class QuantitativeDataset:
73
111
  def __init__(self, path):
74
112
  self.path = Path(path).resolve()
75
113
  with closing(self._connect()) as connection:
76
- if connection.execute("PRAGMA user_version").fetchone()[0] != 1:
114
+ self._storage_version = connection.execute("PRAGMA user_version").fetchone()[0]
115
+ if self._storage_version not in (1, 2, 3):
77
116
  raise ValueError("Unsupported quantitative database schema")
78
117
  row = connection.execute("SELECT value FROM metadata WHERE name='manifest'").fetchone()
79
118
  if row is None:
@@ -88,7 +127,8 @@ class QuantitativeDataset:
88
127
  return connection
89
128
 
90
129
  @classmethod
91
- def create(cls, path, manifest, *, samples, assays, measurements, mappings=()):
130
+ def create(cls, path, manifest, *, samples, assays, measurements, mappings=(),
131
+ pairings=(), relations=(), reference_mappings=()):
92
132
  """Write a new dataset from iterators, validating scope and foreign keys.
93
133
 
94
134
  Never overwrite an existing file. An interrupted/invalid build removes
@@ -106,7 +146,7 @@ class QuantitativeDataset:
106
146
  connection = sqlite3.connect(path)
107
147
  connection.execute("PRAGMA foreign_keys=ON")
108
148
  connection.executescript(_SCHEMA)
109
- connection.execute("PRAGMA user_version=1")
149
+ connection.execute("PRAGMA user_version=3")
110
150
  connection.execute("INSERT INTO metadata VALUES ('manifest', ?)",
111
151
  (_json(manifest.to_dict()),))
112
152
  for reference in manifest.references:
@@ -125,12 +165,68 @@ class QuantitativeDataset:
125
165
  connection.execute("INSERT INTO assay VALUES (?, ?, ?, ?)",
126
166
  (assay.reference.key, assay.assay_id, assay.sample_id,
127
167
  _json(assay.to_dict())))
168
+ for pair in pairings:
169
+ if not isinstance(pair, AssayPair):
170
+ raise TypeError("pairings must contain AssayPairs")
171
+ if (pair.dataset_id, pair.dataset_version) != (manifest.dataset_id, manifest.version):
172
+ raise ValueError("Pairing belongs to a different dataset release")
173
+ if pair.basis in ("same_material", "adjacent_donor_tissue"):
174
+ units = []
175
+ for reference, identifier in ((pair.reference, pair.left_assay_id),
176
+ (pair.right_reference, pair.right_assay_id)):
177
+ row = connection.execute(
178
+ "SELECT s.record FROM assay a JOIN sample s ON a.reference_key=s.reference_key "
179
+ "AND a.sample_id=s.id WHERE a.reference_key=? AND a.id=?",
180
+ (reference.key, identifier)).fetchone()
181
+ if row:
182
+ unit = json.loads(row[0]).get("biological_unit_id")
183
+ if unit is not None:
184
+ units.append(unit)
185
+ if len(set(units)) > 1:
186
+ raise ValueError("Declared pairing contradicts biological-unit identities")
187
+ connection.execute("INSERT INTO assay_pair VALUES (?, ?, ?, ?, ?, ?)",
188
+ (pair.reference.key, pair.pair_id, pair.right_reference.key, pair.left_assay_id,
189
+ pair.right_assay_id, _json(pair.to_dict())))
190
+ relation_links = ({}, {})
191
+ for relation in relations:
192
+ if not isinstance(relation, TargetRelation):
193
+ raise TypeError("relations must contain TargetRelations")
194
+ if (relation.dataset_id, relation.dataset_version) != (manifest.dataset_id, manifest.version):
195
+ raise ValueError("Target relation belongs to a different dataset release")
196
+ for target in (relation.left, relation.right):
197
+ connection.execute("INSERT OR IGNORE INTO target VALUES (?, ?, ?, ?, ?)",
198
+ (target.reference.key, _target_key(target), target.kind,
199
+ target.namespace, target.identifier))
200
+ for index, (first, second) in enumerate(((relation.left, relation.right), (relation.right, relation.left))):
201
+ _check_one_to_one(relation_links[index],
202
+ (relation.relation, first.key, second.reference.key, second.kind, second.namespace), second.key,
203
+ relation.cardinality == "one_to_one")
204
+ connection.execute("INSERT INTO target_relation VALUES (?, ?, ?, ?, ?, ?)",
205
+ (relation.left.reference.key, relation.relation_id,
206
+ relation.right.reference.key,
207
+ _target_key(relation.left), _target_key(relation.right),
208
+ _json(relation.to_dict())))
128
209
  for mapping in mappings:
129
210
  if not isinstance(mapping, TargetMapping):
130
211
  raise TypeError("mappings must contain TargetMappings")
131
212
  connection.execute("INSERT INTO mapping VALUES (?, ?, ?)",
132
213
  (mapping.reference.key, mapping.mapping_id,
133
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))
134
230
  source_ids = {s.asset_id for s in manifest.sources}
135
231
  for observation in measurements:
136
232
  if not isinstance(observation, Measurement):
@@ -140,15 +236,17 @@ class QuantitativeDataset:
140
236
  source = observation.provenance.get("source_asset")
141
237
  if source is not None and source not in source_ids:
142
238
  raise ValueError("Measurement provenance names an unknown source asset")
143
- if source is None and not observation.input_measurements:
239
+ if source is None and not (observation.input_measurements or observation.input_references):
144
240
  raise ValueError("Measurement requires a source_asset or input measurements")
145
241
  reference_key = observation.target.reference.key
146
- 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:
147
245
  if connection.execute(
148
246
  "SELECT 1 FROM measurement WHERE reference_key=? AND id=?",
149
- (reference_key, identifier),
247
+ (input_reference, identifier),
150
248
  ).fetchone() is None:
151
- 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")
152
250
  target = observation.target
153
251
  target_key = _target_key(target)
154
252
  connection.execute("INSERT OR IGNORE INTO target VALUES (?, ?, ?, ?, ?)",
@@ -160,11 +258,12 @@ class QuantitativeDataset:
160
258
  (reference_key, observation.measurement_id, target_key,
161
259
  observation.sample_id, observation.assay_id,
162
260
  observation.quantity, observation.status, _json(record)))
163
- connection.executemany("INSERT INTO input_measurement VALUES (?, ?, ?)",
164
- ((reference_key, observation.measurement_id, identifier)
165
- 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))
166
264
  counts = {table: connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
167
- for table in ("sample", "assay", "mapping", "target", "measurement")}
265
+ for table in ("sample", "assay", "mapping", "target", "measurement",
266
+ "assay_pair", "target_relation", "reference_mapping")}
168
267
  connection.execute("INSERT INTO metadata VALUES ('counts', ?)", (_json(counts),))
169
268
  connection.commit()
170
269
  connection.close()
@@ -205,6 +304,108 @@ class QuantitativeDataset:
205
304
  def mappings(self, *, reference=None):
206
305
  return self._records("mapping", TargetMapping, reference=reference)
207
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
+
355
+ def pairings(self, *, reference=None):
356
+ """Iterate source-declared pairings; legacy releases have none."""
357
+ if self._storage_version == 1:
358
+ self._scope(reference)
359
+ return iter(())
360
+ return self._records("assay_pair", AssayPair, reference=reference)
361
+
362
+ def relations(self, *, reference=None):
363
+ """Iterate same-species relationships, optionally scoped by their left reference."""
364
+ if self._storage_version == 1:
365
+ self._scope(reference)
366
+ return iter(())
367
+ return self._records("target_relation", TargetRelation, reference=reference)
368
+
369
+ def matched_measurements(self, *, allowed_bases, allowed_temporal=("concurrent",),
370
+ reference=None, pair_ids=None, relation_ids=None):
371
+ """Iterate explicit assay/target matches, preserving all values and missing mates.
372
+
373
+ Defaults require concurrent assays; opting into longitudinal or unknown
374
+ timing is explicit. No matching by tissue names or inferred orthology.
375
+ Each side contains its own original observations, never a Cartesian
376
+ multiplication or an aggregate over technical replicates/quantities.
377
+ """
378
+ bases, temporal = set(allowed_bases), set(allowed_temporal)
379
+ if isinstance(pair_ids, str) or isinstance(relation_ids, str):
380
+ raise TypeError("Relationship ID selections must be sequences, not a single string")
381
+ if not bases or bases - set(PAIRING_BASES):
382
+ raise ValueError("Specify valid allowed pairing bases explicitly")
383
+ if not temporal or temporal - set(TEMPORAL_RELATIONS):
384
+ raise ValueError("Specify valid allowed temporal relationships")
385
+ if (pair_ids or relation_ids) and reference is None and len(self._references) > 1:
386
+ raise ValueError("Relationship ID queries across species require a reference")
387
+ relations = tuple(relation for relation in self.relations(reference=reference)
388
+ if relation_ids is None or relation.relation_id in relation_ids)
389
+ for pair in self.pairings(reference=reference):
390
+ if pair.basis not in bases or pair.temporal_relation not in temporal:
391
+ continue
392
+ if pair_ids is not None and pair.pair_id not in pair_ids:
393
+ continue
394
+ by_target = []
395
+ for assay_reference, assay in ((pair.reference, pair.left_assay_id),
396
+ (pair.right_reference, pair.right_assay_id)):
397
+ values = {}
398
+ if assay is not None:
399
+ for value in self.measurements(reference=assay_reference, assay_id=assay):
400
+ values.setdefault(value.target.key, []).append(value)
401
+ by_target.append(values)
402
+ for relation in relations:
403
+ if (relation.left.reference.key == pair.reference.key
404
+ and relation.right.reference.key == pair.right_reference.key):
405
+ yield MatchedMeasurements(pair, relation,
406
+ tuple(by_target[0].get(relation.left.key, ())),
407
+ tuple(by_target[1].get(relation.right.key, ())))
408
+
208
409
  def measurements(self, *, target=None, reference=None, sample_id=None,
209
410
  assay_id=None, quantity=None, status=None):
210
411
  """Iterate native observations; a target match is exact, never orthology.
@@ -239,6 +440,9 @@ class QuantitativeDataset:
239
440
  for row in connection.execute(query, [value for _, value in selected]):
240
441
  values = json.loads(row[0])
241
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"])
242
446
  yield Measurement(**values)
243
447
 
244
448
  @property