Ethan Troy commited on
Commit
20ceb24
·
1 Parent(s): 15a0cf7

feat: add Pocket Atlas development gate

Browse files
AUDIT.md CHANGED
@@ -115,6 +115,12 @@ GtoPdb release 2026.2 was pinned by embedded release metadata and source hashes.
115
 
116
  All 161 target accessions were pinned to reviewed Swiss-Prot sequences from UniProt 2026_02. Joint 50/80 clustering against the complete 2,061-protein Source Atlas and scaffold filtering against all 40,477 Atlas ligands froze a cold-both external holdout with 103 observations, 96 pairs, 78 proteins, 78 scaffolds, and 77 publications. Affinity labels are isolated from the identifier manifest. No predictions have been generated. The holdout is sealed until one frozen candidate passes all internal development gates. See `recovery/GTOPDB_EXTERNAL_HOLDOUT.md` and `recovery/reports/gtopdb-2026.2-external-holdout.json`.
117
 
 
 
 
 
 
 
118
  ### Mixed-pAffinity forensic track
119
 
120
  The deterministic 10,000-pair diagnostic sample is backed by `jglaser/binding_affinity` revision `11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b` and Parquet SHA-256 `d50f4cae794298f32c39f75a9e8603528d20e50e4fa757025def412f5b2a78d8`.
 
115
 
116
  All 161 target accessions were pinned to reviewed Swiss-Prot sequences from UniProt 2026_02. Joint 50/80 clustering against the complete 2,061-protein Source Atlas and scaffold filtering against all 40,477 Atlas ligands froze a cold-both external holdout with 103 observations, 96 pairs, 78 proteins, 78 scaffolds, and 77 publications. Affinity labels are isolated from the identifier manifest. No predictions have been generated. The holdout is sealed until one frozen candidate passes all internal development gates. See `recovery/GTOPDB_EXTERNAL_HOLDOUT.md` and `recovery/reports/gtopdb-2026.2-external-holdout.json`.
117
 
118
+ ### Pocket Atlas development gate (2026-07-21)
119
+
120
+ Pocket Atlas mapped 148 homology-safe development proteins to exact or at least 80%-coverage reviewed UniProt sequences and downloaded 145 canonical, versioned AlphaFold monomer structures. Pocketeer extracted 64 confidence, shape, contact-density, and top-pocket descriptors without failure. The checked acquisition workflow reproduced the complete mapping and structure manifest byte-for-byte.
121
+
122
+ On 1,765 development observations outside every reserved test homology cluster, ligand LightGBM achieved RMSE `1.6682`, the ESM residual achieved `1.6435`, and the best structure-containing residual achieved `1.6643`. Structure therefore worsened RMSE by `0.0208` relative to ESM; the incremental cluster-bootstrap interval was `[-0.0481, 0.0083]`, improvement probability was `0.0905`, and one fold regressed by `0.3835`. The predeclared structure gate rejected further training. No benchmark-test or external-holdout evaluation, GPU run, checkpoint, or weight publication occurred. See `recovery/POCKET_ATLAS_GATE.md` and `recovery/reports/bindingdb-v2-pocket-atlas-development-gate.json`.
123
+
124
  ### Mixed-pAffinity forensic track
125
 
126
  The deterministic 10,000-pair diagnostic sample is backed by `jglaser/binding_affinity` revision `11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b` and Parquet SHA-256 `d50f4cae794298f32c39f75a9e8603528d20e50e4fa757025def412f5b2a78d8`.
recovery/MODEL_EVALUATION_V2.md CHANGED
@@ -117,13 +117,21 @@ manifest and no model predictions have been generated. See
117
  `GTOPDB_EXTERNAL_HOLDOUT.md` and
118
  `reports/gtopdb-2026.2-external-holdout.json`.
119
 
120
- The next development candidate is Pocket Atlas: versioned AlphaFold confidence
121
- and alpha-sphere pocket geometry fused with the existing ligand control. It has
122
- usable reviewed-UniProt placement for 1,272 of 1,765 homology-safe development
123
- observations. It must first demonstrate benefit on repeated homology-grouped
124
- development folds. Only a frozen candidate passing the RMSE, bootstrap,
125
- catastrophic-fold, and protein-macro gates may receive the one permitted
126
- external-holdout evaluation.
 
 
 
 
 
 
 
 
127
 
128
  ## Reproduce
129
 
 
117
  `GTOPDB_EXTERNAL_HOLDOUT.md` and
118
  `reports/gtopdb-2026.2-external-holdout.json`.
119
 
120
+ Pocket Atlas tested versioned AlphaFold confidence and deterministic
121
+ alpha-sphere pocket geometry on the 1,765 homology-safe development rows. The
122
+ best structure-containing residual achieved RMSE `1.6643`, barely improving the
123
+ ligand control's `1.6682` and worsening the ESM-only residual's `1.6435` by
124
+ `0.0208`. Its incremental cluster-bootstrap interval was `[-0.0481, 0.0083]`
125
+ and one fold regressed by `0.3835`, so the predeclared gate rejected further
126
+ structure training without touching either holdout. See `POCKET_ATLAS_GATE.md`
127
+ and `reports/bindingdb-v2-pocket-atlas-development-gate.json`.
128
+
129
+ The next candidate must provide ligand-conditioned interaction information or
130
+ new exact-Kd training observations that survive the reserved homology and
131
+ scaffold manifests. More capacity over global or ligand-independent protein
132
+ features is not justified. Only a frozen candidate passing the RMSE, bootstrap,
133
+ catastrophic-fold, incremental-signal, and protein-macro gates may receive the
134
+ one permitted external-holdout evaluation.
135
 
136
  ## Reproduce
137
 
recovery/POCKET_ATLAS_GATE.md ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pocket Atlas Development Gate
2
+
3
+ ## Decision
4
+
5
+ **Reject the structure candidate. Do not train or publish weights from this
6
+ hypothesis.**
7
+
8
+ Pocket Atlas added versioned AlphaFold confidence and alpha-sphere pocket
9
+ geometry to the strongest ligand-only control. The best structure-containing
10
+ residual did not beat the ESM-only residual, its grouped confidence interval
11
+ crossed zero, and one fold regressed catastrophically.
12
+
13
+ No benchmark-test or sealed GtoPdb external-holdout prediction was generated.
14
+ The experiment cost $0 in cloud compute.
15
+
16
+ ## Development population
17
+
18
+ The original cold-protein test was already observed by earlier work and remains
19
+ spent. This gate used development data only:
20
+
21
+ 1. Start with the original train and validation observations.
22
+ 2. Jointly use the Source Atlas MMseqs2 50% identity / 80% bidirectional
23
+ coverage clusters.
24
+ 3. Reserve all 34 clusters containing any benchmark test protein.
25
+ 4. Remove 292 homologous train/validation observations.
26
+ 5. Evaluate exactly 1,765 observations, 1,717 pairs, 278 proteins, and 187
27
+ clusters with five outer grouped folds.
28
+
29
+ Each fold contained 353 observations. The prediction-sidecar audit found zero
30
+ test pairs and zero reserved test clusters.
31
+
32
+ ## Structure source contract
33
+
34
+ BindingDB source provenance linked benchmark proteins to UniProt accessions.
35
+ A structure was eligible only when:
36
+
37
+ - exactly one accession was available;
38
+ - the UniProt entry was reviewed;
39
+ - the benchmark sequence was exact or a contiguous UniProt
40
+ subsequence/supersequence;
41
+ - contiguous sequence coverage was at least 80%; and
42
+ - AlphaFold returned the canonical DeepMind monomer whose model identity was
43
+ exactly `AF-{accession}-F1`.
44
+
45
+ This explicitly rejects AlphaFold isoforms, alternate models, and complex
46
+ predictions returned by the same API endpoint.
47
+
48
+ Source results:
49
+
50
+ | Item | Count |
51
+ |---|---:|
52
+ | Homology-safe proteins | 278 |
53
+ | Sequence-eligible proteins | 148 |
54
+ | Eligible accessions | 146 |
55
+ | Canonical AlphaFold models | 145 |
56
+ | Unavailable canonical models | 1 (`P0DTD1`) |
57
+ | Downloaded PDB bytes | 56,834,558 |
58
+
59
+ All coordinates are versioned AlphaFold URLs and locally hashed. The checked
60
+ source-preparation script reproduced both the protein mapping and complete
61
+ structure manifest byte-for-byte.
62
+
63
+ ## Structure features
64
+
65
+ `pocketeer==0.3.1` generated 64 fixed protein-only features:
66
+
67
+ - AlphaFold global confidence and pLDDT distributions;
68
+ - sequence, atom, and C-alpha counts;
69
+ - radius of gyration and principal-axis dimensions;
70
+ - shape eigenvalues and C-alpha contact density;
71
+ - pocket count and aggregate scores/volumes; and
72
+ - top-three pocket score, volume, sphere count, residue count, radius, solvent
73
+ accessibility, pLDDT, and residue chemistry fractions.
74
+
75
+ Two mapping-quality indicators captured UniProt coverage and exact-sequence
76
+ placement. Missing structures received zero descriptors plus an unavailable
77
+ indicator.
78
+
79
+ Extraction results:
80
+
81
+ | Item | Count |
82
+ |---|---:|
83
+ | Requested structures | 145 |
84
+ | Successful structures | 145 |
85
+ | Failed structures | 0 |
86
+ | Minimum detected pockets | 1 |
87
+ | Maximum detected pockets | 47 |
88
+ | Zero-pocket structures | 0 |
89
+ | Development observations with structure | 1,189 / 1,765 |
90
+ | Development proteins with structure | 147 / 278 |
91
+
92
+ A repeated P00338 extraction was exactly equal with maximum absolute feature
93
+ difference `0.0`.
94
+
95
+ ## Predeclared gate
96
+
97
+ A structure candidate had to satisfy all of the following:
98
+
99
+ 1. improve RMSE over ligand LightGBM by at least `0.05`;
100
+ 2. have a positive lower bound in a 2,000-replicate protein-cluster bootstrap;
101
+ 3. maintain positive protein-macro Pearson correlation;
102
+ 4. avoid a fold regression worse than `0.10` RMSE;
103
+ 5. improve at least `0.02` RMSE over the ESM-only residual; and
104
+ 6. have a positive incremental bootstrap lower bound versus ESM.
105
+
106
+ The ligand LightGBM was cross-fitted within every outer-training fold before
107
+ residual fitting. Ridge regularization was selected only inside grouped inner
108
+ folds.
109
+
110
+ ## Results
111
+
112
+ | Model | RMSE | Change vs ligand control | Gate |
113
+ |---|---:|---:|---|
114
+ | Ligand Morgan + descriptors LightGBM | 1.66824 | reference | control |
115
+ | ESM protein residual | 1.64349 | +0.02475 | fail |
116
+ | Structure residual | 1.70274 | -0.03450 | fail |
117
+ | ESM + structure residual | 1.66427 | +0.00397 | **fail** |
118
+
119
+ For the best structure-containing model:
120
+
121
+ - incremental change versus ESM: `-0.02078` RMSE;
122
+ - incremental 95% cluster-bootstrap interval: `[-0.04808, 0.00835]`;
123
+ - probability of any incremental improvement: `0.0905`;
124
+ - worst fold change versus ligand control: `-0.38354`; and
125
+ - protein-macro Pearson remained positive but was insufficient to pass.
126
+
127
+ Structure-only behavior was also negative:
128
+
129
+ - RMSE change versus control: `-0.03450`;
130
+ - bootstrap interval: `[-0.09739, 0.03359]`; and
131
+ - probability of improvement: `0.1635`.
132
+
133
+ ## Interpretation
134
+
135
+ Global fold and ligand-independent top-pocket descriptors do not supply useful
136
+ incremental affinity information here. AlphaFold structure availability alone
137
+ is not the missing model ingredient. A future structure hypothesis would need
138
+ a ligand-conditioned mechanism, such as a defensibly selected binding site,
139
+ co-complex geometry, or docking/interaction features. Repeating this experiment
140
+ with a larger neural head is not justified by the grouped CPU evidence.
141
+
142
+ The independent GtoPdb cold-both holdout remains sealed. It must not be used to
143
+ choose the next model family.
144
+
145
+ ## Reproduce
146
+
147
+ Prepare the pinned structure snapshot:
148
+
149
+ ```bash
150
+ cd recovery
151
+ PYTHONPATH=src uv run python scripts/prepare_pocket_atlas_sources.py \
152
+ --sample artifacts/bindingdb-202607-benchmark-v2/sample.jsonl \
153
+ --manifest artifacts/bindingdb-202607-benchmark-v2/split-cold_protein_exact.jsonl \
154
+ --source-records artifacts/bindingdb-202607-source-aware-final/source_records.jsonl \
155
+ --clusters /path/to/source-atlas/mmseqs-identity-50-coverage-80/clusters_cluster.tsv \
156
+ --uniprot /path/to/pinned-uniprot-2026_02.tsv \
157
+ --output-dir /path/to/pocket-atlas-sources
158
+ ```
159
+
160
+ Create an isolated pocket-tool environment and extract features:
161
+
162
+ ```bash
163
+ uv venv --python 3.11 /path/to/pocket-tools
164
+ uv pip install --python /path/to/pocket-tools/bin/python pocketeer==0.3.1
165
+
166
+ PYTHONPATH=src /path/to/pocket-tools/bin/python \
167
+ scripts/extract_pocket_atlas_features.py \
168
+ --structure-manifest /path/to/pocket-atlas-sources/structure_manifest.json \
169
+ --structures-dir /path/to/pocket-atlas-sources/structures \
170
+ --output-dir /path/to/pocket-atlas-features \
171
+ --workers 4
172
+ ```
173
+
174
+ Run the development-only gate:
175
+
176
+ ```bash
177
+ PYTHONPATH=src uv run python scripts/run_pocket_atlas_gate.py \
178
+ --sample artifacts/bindingdb-202607-benchmark-v2/sample.jsonl \
179
+ --manifest artifacts/bindingdb-202607-benchmark-v2/split-cold_protein_exact.jsonl \
180
+ --embeddings artifacts/bindingdb-202607-benchmark-v2/frozen-embeddings.npz \
181
+ --clusters /path/to/source-atlas/mmseqs-identity-50-coverage-80/clusters_cluster.tsv \
182
+ --protein-mapping /path/to/pocket-atlas-sources/protein_mapping.jsonl \
183
+ --pocket-features /path/to/pocket-atlas-features/features.json \
184
+ --output-dir /path/to/pocket-atlas-gate
185
+ ```
186
+
187
+ Aggregate evidence is checked in at
188
+ `reports/bindingdb-v2-pocket-atlas-development-gate.json`. Generated PDB files,
189
+ features, predictions, and labels remain outside Git.
recovery/reports/bindingdb-v2-pocket-atlas-development-gate.json ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": "mitointeract-pocket-atlas-checked-report/v1",
3
+ "experiment": "pocket_atlas_development_gate",
4
+ "status": "development_only_no_test_or_external_evaluation",
5
+ "decision": "reject_structure_candidate_no_further_training",
6
+ "target": "pKd",
7
+ "seed": 42,
8
+ "benchmark_test_evaluations": 0,
9
+ "external_holdout_evaluations": 0,
10
+ "cost_usd": 0.0,
11
+ "population": {
12
+ "sample_rows": 2327,
13
+ "source_development_rows": 2057,
14
+ "development_rows_excluded_for_test_cluster_homology": 292,
15
+ "safe_development_rows": 1765,
16
+ "safe_development_pairs": 1717,
17
+ "safe_development_proteins": 278,
18
+ "safe_development_clusters": 187,
19
+ "benchmark_test_rows_excluded": 270,
20
+ "benchmark_test_clusters_reserved": 34
21
+ },
22
+ "source_mapping": {
23
+ "uniprot_release": "2026_02",
24
+ "uniprot_release_date": "10-June-2026",
25
+ "uniprot_api_deployment_date": "11-July-2026",
26
+ "uniprot_entries": 229,
27
+ "uniprot_tsv_sha256": "68c3b65d90446de0df13960242e30d7d60f9a60649374a959ff34405996a2c75",
28
+ "eligible_proteins": 148,
29
+ "eligible_accessions": 146,
30
+ "ineligible": {
31
+ "ambiguous_accession": 1,
32
+ "missing_accession": 29,
33
+ "sequence_placement_or_coverage": 99,
34
+ "unreviewed_uniprot_entry": 1
35
+ },
36
+ "minimum_contiguous_sequence_coverage": 0.8,
37
+ "protein_mapping_sha256": "db7b8812dd1face08e590c5a51a19cf6863f9de11bad8ae30f509d9b3e7084b1"
38
+ },
39
+ "alphafold_snapshot": {
40
+ "api": "https://alphafold.ebi.ac.uk/api/prediction/",
41
+ "metadata_accessions": 146,
42
+ "gdm_monomer_entries": 280,
43
+ "canonical_selection": "modelEntityId == AF-{accession}-F1; provider GDM; monomer only",
44
+ "canonical_models": 145,
45
+ "unavailable_accessions": [
46
+ "P0DTD1"
47
+ ],
48
+ "downloaded_structures": 145,
49
+ "structure_bytes": 56834558,
50
+ "structure_manifest_sha256": "9f985679d4d17aae8c7a11bed849c4d4062c7d682b0b40905435785e1916f9c4"
51
+ },
52
+ "structure_features": {
53
+ "tool": "pocketeer",
54
+ "version": "0.3.1",
55
+ "feature_count": 64,
56
+ "mapping_quality_feature_count": 2,
57
+ "requested_structures": 145,
58
+ "successful_structures": 145,
59
+ "failed_structures": 0,
60
+ "minimum_pockets": 1,
61
+ "maximum_pockets": 47,
62
+ "zero_pocket_structures": 0,
63
+ "feature_sha256": "200d0d8cd15c0d57cb51096d8f56ef60c84d83493ecac12eed7a0064aa6f930e",
64
+ "determinism_check": {
65
+ "accession": "P00338",
66
+ "exact_equal": true,
67
+ "maximum_absolute_difference": 0.0
68
+ }
69
+ },
70
+ "structure_coverage": {
71
+ "safe_rows": 1765,
72
+ "available_rows": 1189,
73
+ "missing_rows": 576,
74
+ "safe_proteins": 278,
75
+ "available_proteins": 147,
76
+ "missing_proteins": 131
77
+ },
78
+ "control": {
79
+ "name": "ligand_morgan_descriptors_lightgbm",
80
+ "metrics": {
81
+ "rmse": 1.6682415337174723,
82
+ "mae": 1.3337803693102785,
83
+ "pearson_r": 0.33782116805873186,
84
+ "spearman_r": 0.29689795196092983,
85
+ "r2": 0.08151297017567782
86
+ },
87
+ "protein_macro_mean_pearson_r": 0.5207683213714444
88
+ },
89
+ "variants": {
90
+ "protein_embedding_ridge_residual": {
91
+ "metrics": {
92
+ "rmse": 1.6434931065733804,
93
+ "mae": 1.326762151114221,
94
+ "pearson_r": 0.44720924687855346,
95
+ "spearman_r": 0.4453109776035747,
96
+ "r2": 0.10856241169224723
97
+ },
98
+ "rmse_improvement_vs_control": 0.024748427144091867,
99
+ "cluster_bootstrap": {
100
+ "iterations": 2000,
101
+ "ci_2_5": -0.1226979699222684,
102
+ "ci_97_5": 0.14593144177553413,
103
+ "probability_improvement": 0.6405
104
+ },
105
+ "fold_rmse_improvements": [
106
+ 0.19749483894689246,
107
+ 0.03203609759448267,
108
+ -0.3950203091840785,
109
+ 0.26783291384310304,
110
+ 0.013510866159464952
111
+ ],
112
+ "protein_macro_mean_pearson_r": 0.5314499173271257,
113
+ "passed": false
114
+ },
115
+ "structure_ridge_residual": {
116
+ "metrics": {
117
+ "rmse": 1.7027422554992393,
118
+ "mae": 1.3740292703037023,
119
+ "pearson_r": 0.32258584622423186,
120
+ "spearman_r": 0.2630759939864411,
121
+ "r2": 0.04312987314367667
122
+ },
123
+ "rmse_improvement_vs_control": -0.034500721781766996,
124
+ "cluster_bootstrap": {
125
+ "iterations": 2000,
126
+ "ci_2_5": -0.09738584672656869,
127
+ "ci_97_5": 0.03358697436359342,
128
+ "probability_improvement": 0.1635
129
+ },
130
+ "fold_rmse_improvements": [
131
+ -0.052726149391103094,
132
+ -0.17034724269050883,
133
+ 0.0566650978161638,
134
+ 0.011227189056832731,
135
+ -0.019131441509435243
136
+ ],
137
+ "protein_macro_mean_pearson_r": 0.4867412848102915,
138
+ "passed": false
139
+ },
140
+ "protein_embedding_plus_structure_ridge_residual": {
141
+ "metrics": {
142
+ "rmse": 1.6642723336109313,
143
+ "mae": 1.3471499523001278,
144
+ "pearson_r": 0.43435936114789336,
145
+ "spearman_r": 0.42805907694460715,
146
+ "r2": 0.08587843133505468
147
+ },
148
+ "rmse_improvement_vs_control": 0.003969200106541004,
149
+ "cluster_bootstrap": {
150
+ "iterations": 2000,
151
+ "ci_2_5": -0.1343544421249894,
152
+ "ci_97_5": 0.11774505669976526,
153
+ "probability_improvement": 0.525
154
+ },
155
+ "fold_rmse_improvements": [
156
+ 0.13279607681808803,
157
+ 0.015698267195580762,
158
+ -0.3835410243143167,
159
+ 0.249759930531807,
160
+ -0.004686872948701071
161
+ ],
162
+ "protein_macro_mean_pearson_r": 0.5255208296082974,
163
+ "passed": false
164
+ }
165
+ },
166
+ "predeclared_structure_gate": {
167
+ "best_structure_variant": "protein_embedding_plus_structure_ridge_residual",
168
+ "minimum_rmse_improvement_vs_control": 0.05,
169
+ "actual_rmse_improvement_vs_control": 0.003969200106541004,
170
+ "minimum_incremental_rmse_improvement_vs_esm": 0.02,
171
+ "actual_incremental_rmse_improvement_vs_esm": -0.020779227037550863,
172
+ "incremental_cluster_bootstrap": {
173
+ "iterations": 2000,
174
+ "seed": 43,
175
+ "ci_2_5": -0.048075518247379725,
176
+ "ci_97_5": 0.008345387666166259,
177
+ "probability_improvement": 0.0905
178
+ },
179
+ "bootstrap_lower_bound_positive_vs_control": false,
180
+ "incremental_bootstrap_lower_bound_positive_vs_esm": false,
181
+ "no_catastrophic_fold": false,
182
+ "protein_macro_pearson_positive": true,
183
+ "passed": false
184
+ },
185
+ "verification": {
186
+ "prediction_rows": 1765,
187
+ "unique_observation_ids": 1765,
188
+ "fold_rows": {
189
+ "0": 353,
190
+ "1": 353,
191
+ "2": 353,
192
+ "3": 353,
193
+ "4": 353
194
+ },
195
+ "test_pairs_in_predictions": 0,
196
+ "test_clusters_in_predictions": 0,
197
+ "all_predictions_finite": true,
198
+ "all_reconstructed_metric_differences": 0.0,
199
+ "source_reproduction": {
200
+ "mapping_exact_match": true,
201
+ "structure_manifest_exact_match": true
202
+ }
203
+ },
204
+ "inputs": {
205
+ "sample_sha256": "4b022ee8e3f1a4d00499bcaea218bad730f0181fedd7dda7d6a5a3ea370bb7ea",
206
+ "manifest_sha256": "fb0a4dc63b8074018a1b7f02b863b8fa0cf6117930c3298b7c80133867cb2798",
207
+ "source_records_sha256": "1e9963217b9ed65528ca477adcba66beea1fa341a1c1c6d41cd789332ab85bbb",
208
+ "clusters_sha256": "0162c535ba224b64f328555bb18628d945f5aeb98a4bb7b9f1f832394baddbf6",
209
+ "frozen_embeddings_sha256": "e4c8c3e510b9f28c0b556aa429b79025a07712362e37a2e99a365a8fca844bff"
210
+ },
211
+ "artifacts": {
212
+ "predictions_sha256": "6d8b251deccf2881d9cc8bdf95e6f8a8ec4ecee065c53b56f5ea2b17907bee7f",
213
+ "predictions_in_git": false,
214
+ "structures_in_git": false,
215
+ "checkpoint_created": false,
216
+ "weights_published": false
217
+ },
218
+ "packages": {
219
+ "lightgbm": "4.7.0",
220
+ "numpy_gate": "2.5.1",
221
+ "numpy_pocket_tool": "2.4.6",
222
+ "pocketeer": "0.3.1",
223
+ "biotite": "1.6.0",
224
+ "scipy_pocket_tool": "1.17.1",
225
+ "rdkit": "2026.3.4",
226
+ "scikit_learn": "1.9.0"
227
+ },
228
+ "publication_decision": "publish_negative_report_only"
229
+ }
recovery/scripts/extract_pocket_atlas_features.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Extract pinned AlphaFold confidence and pocketeer descriptors."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import importlib.metadata
9
+ import json
10
+ import logging
11
+ from concurrent.futures import ProcessPoolExecutor, as_completed
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from mitointeract_recovery.pocket_features import (
16
+ FEATURE_NAMES,
17
+ POCKET_TOOL,
18
+ POCKET_TOOL_VERSION,
19
+ extract_structure_features,
20
+ )
21
+
22
+ SCHEMA_VERSION = "mitointeract-pocket-atlas-features/v1"
23
+
24
+
25
+ def sha256_file(path: Path) -> str:
26
+ with path.open("rb") as handle:
27
+ return hashlib.file_digest(handle, "sha256").hexdigest()
28
+
29
+
30
+ def worker(accession: str, path_text: str, metadata: dict[str, Any]) -> tuple[str, dict[str, Any]]:
31
+ logging.getLogger().setLevel(logging.WARNING)
32
+ vector, diagnostics = extract_structure_features(Path(path_text), metadata)
33
+ return accession, {
34
+ "features": vector.astype(float).tolist(),
35
+ "diagnostics": diagnostics,
36
+ }
37
+
38
+
39
+ def main() -> None:
40
+ parser = argparse.ArgumentParser()
41
+ parser.add_argument("--structure-manifest", type=Path, required=True)
42
+ parser.add_argument("--structures-dir", type=Path, required=True)
43
+ parser.add_argument("--output-dir", type=Path, required=True)
44
+ parser.add_argument("--workers", type=int, default=4)
45
+ args = parser.parse_args()
46
+ if args.workers <= 0:
47
+ raise ValueError("workers must be positive")
48
+ if args.output_dir.exists() and any(args.output_dir.iterdir()):
49
+ raise FileExistsError(f"output directory is not empty: {args.output_dir}")
50
+ args.output_dir.mkdir(parents=True, exist_ok=True)
51
+ manifest = json.loads(args.structure_manifest.read_text())
52
+ if manifest.get("schema_version") != "mitointeract-pocket-atlas-structures/v1":
53
+ raise ValueError("unexpected structure manifest schema")
54
+ files = manifest.get("files")
55
+ if not isinstance(files, dict) or not files:
56
+ raise ValueError("structure manifest has no files")
57
+ version = importlib.metadata.version(POCKET_TOOL)
58
+ if version != POCKET_TOOL_VERSION:
59
+ raise ValueError(
60
+ f"unexpected {POCKET_TOOL} version: {version}; expected {POCKET_TOOL_VERSION}"
61
+ )
62
+ jobs: list[tuple[str, Path, dict[str, Any]]] = []
63
+ for accession, metadata in sorted(files.items()):
64
+ path = args.structures_dir / f"{accession}.pdb"
65
+ if not path.is_file():
66
+ raise FileNotFoundError(path)
67
+ actual = sha256_file(path)
68
+ if actual != metadata["sha256"]:
69
+ raise ValueError(f"structure hash mismatch for {accession}")
70
+ jobs.append((accession, path, metadata))
71
+
72
+ results: dict[str, dict[str, Any]] = {}
73
+ failures: dict[str, str] = {}
74
+ with ProcessPoolExecutor(max_workers=args.workers) as executor:
75
+ futures = {
76
+ executor.submit(worker, accession, str(path), metadata): accession
77
+ for accession, path, metadata in jobs
78
+ }
79
+ for future in as_completed(futures):
80
+ accession = futures[future]
81
+ try:
82
+ returned_accession, result = future.result()
83
+ if returned_accession != accession:
84
+ raise RuntimeError("worker accession mismatch")
85
+ if len(result["features"]) != len(FEATURE_NAMES):
86
+ raise RuntimeError("worker feature width mismatch")
87
+ results[accession] = result
88
+ except Exception as exc:
89
+ failures[accession] = f"{type(exc).__name__}: {exc}"
90
+
91
+ features_path = args.output_dir / "features.json"
92
+ features_path.write_text(
93
+ json.dumps(
94
+ {
95
+ "schema_version": SCHEMA_VERSION,
96
+ "feature_names": FEATURE_NAMES,
97
+ "accessions": {key: results[key] for key in sorted(results)},
98
+ },
99
+ indent=2,
100
+ sort_keys=True,
101
+ )
102
+ + "\n"
103
+ )
104
+ report = {
105
+ "schema_version": SCHEMA_VERSION,
106
+ "tool": {"name": POCKET_TOOL, "version": version},
107
+ "feature_count": len(FEATURE_NAMES),
108
+ "requested_structures": len(jobs),
109
+ "successful_structures": len(results),
110
+ "failed_structures": len(failures),
111
+ "failures": failures,
112
+ "pocket_counts": {
113
+ "minimum": min(result["diagnostics"]["pockets"] for result in results.values()),
114
+ "maximum": max(result["diagnostics"]["pockets"] for result in results.values()),
115
+ "zero": sum(result["diagnostics"]["pockets"] == 0 for result in results.values()),
116
+ }
117
+ if results
118
+ else None,
119
+ "inputs": {
120
+ "structure_manifest": {
121
+ "bytes": args.structure_manifest.stat().st_size,
122
+ "sha256": sha256_file(args.structure_manifest),
123
+ },
124
+ "total_structure_bytes": sum(path.stat().st_size for _, path, _ in jobs),
125
+ },
126
+ "output": {
127
+ "bytes": features_path.stat().st_size,
128
+ "sha256": sha256_file(features_path),
129
+ },
130
+ }
131
+ (args.output_dir / "audit.json").write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
132
+ print(json.dumps(report, indent=2, sort_keys=True))
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()
recovery/scripts/prepare_pocket_atlas_sources.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Prepare the pinned development-only Pocket Atlas source snapshot."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import hashlib
9
+ import json
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ from collections import Counter
14
+ from concurrent.futures import ThreadPoolExecutor, as_completed
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from mitointeract_recovery.chimera import read_manifest, read_mmseqs_clusters
19
+ from mitointeract_recovery.pocket_sources import (
20
+ build_protein_mapping,
21
+ select_canonical_alphafold_model,
22
+ )
23
+
24
+ EXPECTED_SHA256 = {
25
+ "sample": "4b022ee8e3f1a4d00499bcaea218bad730f0181fedd7dda7d6a5a3ea370bb7ea",
26
+ "manifest": "fb0a4dc63b8074018a1b7f02b863b8fa0cf6117930c3298b7c80133867cb2798",
27
+ "source_records": "1e9963217b9ed65528ca477adcba66beea1fa341a1c1c6d41cd789332ab85bbb",
28
+ "clusters": "0162c535ba224b64f328555bb18628d945f5aeb98a4bb7b9f1f832394baddbf6",
29
+ "uniprot": "68c3b65d90446de0df13960242e30d7d60f9a60649374a959ff34405996a2c75",
30
+ }
31
+ UNIPROT_HEADER = (
32
+ "Entry",
33
+ "Entry Name",
34
+ "Reviewed",
35
+ "Organism",
36
+ "Organism (ID)",
37
+ "Protein names",
38
+ "Sequence",
39
+ "Sequence version",
40
+ )
41
+ AFDB_API = "https://alphafold.ebi.ac.uk/api/prediction/"
42
+
43
+
44
+ def sha256_file(path: Path) -> str:
45
+ with path.open("rb") as handle:
46
+ return hashlib.file_digest(handle, "sha256").hexdigest()
47
+
48
+
49
+ def read_jsonl(path: Path) -> list[dict[str, Any]]:
50
+ with path.open() as handle:
51
+ return [json.loads(line) for line in handle if line.strip()]
52
+
53
+
54
+ def read_source_accessions(path: Path) -> dict[str, str]:
55
+ output: dict[str, str] = {}
56
+ with path.open() as handle:
57
+ for line_number, line in enumerate(handle, start=1):
58
+ envelope = json.loads(line)
59
+ row = envelope.get("source_record")
60
+ if not isinstance(row, dict):
61
+ raise ValueError(f"source record row {line_number} lacks payload")
62
+ source_id = str(row.get("source_record_id", "")).strip()
63
+ if not source_id or source_id in output:
64
+ raise ValueError(f"invalid or duplicate source record row {line_number}")
65
+ provenance = row.get("provenance")
66
+ if not isinstance(provenance, dict):
67
+ raise ValueError(f"source record row {line_number} lacks provenance")
68
+ output[source_id] = str(provenance.get("swissprot_primary_id") or "").strip()
69
+ return output
70
+
71
+
72
+ def read_uniprot(path: Path) -> dict[str, dict[str, Any]]:
73
+ output: dict[str, dict[str, Any]] = {}
74
+ with path.open(newline="") as handle:
75
+ reader = csv.DictReader(handle, delimiter="\t")
76
+ if tuple(reader.fieldnames or ()) != UNIPROT_HEADER:
77
+ raise ValueError("unexpected UniProt TSV schema")
78
+ for line_number, row in enumerate(reader, start=2):
79
+ accession = row["Entry"].strip()
80
+ if not accession or accession in output:
81
+ raise ValueError(f"invalid or duplicate UniProt row {line_number}")
82
+ sequence = row["Sequence"].strip()
83
+ if not sequence:
84
+ raise ValueError(f"empty UniProt sequence at row {line_number}")
85
+ output[accession] = {
86
+ "reviewed": row["Reviewed"].strip(),
87
+ "sequence": sequence,
88
+ "sequence_version": int(row["Sequence version"]),
89
+ }
90
+ return output
91
+
92
+
93
+ def get_metadata(accession: str) -> tuple[str, int, list[dict[str, Any]]]:
94
+ last_error: Exception | None = None
95
+ for attempt in range(4):
96
+ try:
97
+ with urllib.request.urlopen(AFDB_API + accession, timeout=60) as response:
98
+ status = int(response.status)
99
+ data = json.loads(response.read())
100
+ if not isinstance(data, list):
101
+ raise ValueError("AlphaFold API response is not a list")
102
+ selected = [
103
+ model
104
+ for model in data
105
+ if model.get("providerId") == "GDM"
106
+ and not model.get("isComplex", False)
107
+ and model.get("entityType") == "protein"
108
+ ]
109
+ return accession, status, selected
110
+ except urllib.error.HTTPError as exc:
111
+ if exc.code == 429:
112
+ time.sleep(2**attempt)
113
+ continue
114
+ last_error = exc
115
+ time.sleep(2**attempt)
116
+ except Exception as exc:
117
+ last_error = exc
118
+ time.sleep(2**attempt)
119
+ raise RuntimeError(f"AlphaFold metadata request failed for {accession}: {last_error}")
120
+
121
+
122
+ def download_structure(
123
+ accession: str, model: dict[str, Any], structures_dir: Path
124
+ ) -> tuple[str, dict[str, Any]]:
125
+ last_error: Exception | None = None
126
+ url = str(model["pdbUrl"])
127
+ for attempt in range(4):
128
+ try:
129
+ with urllib.request.urlopen(url, timeout=180) as response:
130
+ content = response.read()
131
+ if b"ATOM" not in content[:200_000]:
132
+ raise ValueError("response is not PDB coordinate content")
133
+ path = structures_dir / f"{accession}.pdb"
134
+ path.write_bytes(content)
135
+ return accession, {
136
+ "model_entity_id": model["modelEntityId"],
137
+ "latest_version": model["latestVersion"],
138
+ "model_created_date": model.get("modelCreatedDate"),
139
+ "sequence_checksum": model.get("sequenceChecksum"),
140
+ "sequence_start": model.get("sequenceStart"),
141
+ "sequence_end": model.get("sequenceEnd"),
142
+ "global_metric_value": model.get("globalMetricValue"),
143
+ "fraction_plddt_very_high": model.get("fractionPlddtVeryHigh"),
144
+ "fraction_plddt_confident": model.get("fractionPlddtConfident"),
145
+ "fraction_plddt_low": model.get("fractionPlddtLow"),
146
+ "fraction_plddt_very_low": model.get("fractionPlddtVeryLow"),
147
+ "pdb_url": url,
148
+ "bytes": len(content),
149
+ "sha256": hashlib.sha256(content).hexdigest(),
150
+ }
151
+ except Exception as exc:
152
+ last_error = exc
153
+ time.sleep(2**attempt)
154
+ raise RuntimeError(f"AlphaFold coordinate download failed for {accession}: {last_error}")
155
+
156
+
157
+ def parse_args() -> argparse.Namespace:
158
+ parser = argparse.ArgumentParser()
159
+ parser.add_argument("--sample", type=Path, required=True)
160
+ parser.add_argument("--manifest", type=Path, required=True)
161
+ parser.add_argument("--source-records", type=Path, required=True)
162
+ parser.add_argument("--clusters", type=Path, required=True)
163
+ parser.add_argument("--uniprot", type=Path, required=True)
164
+ parser.add_argument("--output-dir", type=Path, required=True)
165
+ parser.add_argument("--workers", type=int, default=8)
166
+ return parser.parse_args()
167
+
168
+
169
+ def main() -> None:
170
+ args = parse_args()
171
+ paths = {
172
+ "sample": args.sample,
173
+ "manifest": args.manifest,
174
+ "source_records": args.source_records,
175
+ "clusters": args.clusters,
176
+ "uniprot": args.uniprot,
177
+ }
178
+ for name, path in paths.items():
179
+ if not path.is_file():
180
+ raise FileNotFoundError(path)
181
+ actual = sha256_file(path)
182
+ if actual != EXPECTED_SHA256[name]:
183
+ raise ValueError(f"{name} hash mismatch: {actual}")
184
+ if args.output_dir.exists():
185
+ raise FileExistsError(args.output_dir)
186
+ metadata_dir = args.output_dir / "afdb_metadata"
187
+ structures_dir = args.output_dir / "structures"
188
+ metadata_dir.mkdir(parents=True)
189
+ structures_dir.mkdir()
190
+ sample_rows = read_jsonl(args.sample)
191
+ mapping_rows, mapping_audit = build_protein_mapping(
192
+ sample_rows=sample_rows,
193
+ manifest=read_manifest(args.manifest),
194
+ source_accessions=read_source_accessions(args.source_records),
195
+ clusters=read_mmseqs_clusters(args.clusters),
196
+ uniprot_entries=read_uniprot(args.uniprot),
197
+ )
198
+ mapping_path = args.output_dir / "protein_mapping.jsonl"
199
+ mapping_path.write_text("".join(json.dumps(row, sort_keys=True) + "\n" for row in mapping_rows))
200
+ eligible_accessions = sorted({str(row["accession"]) for row in mapping_rows if row["eligible"]})
201
+ metadata: dict[str, list[dict[str, Any]]] = {}
202
+ statuses: Counter[int] = Counter()
203
+ with ThreadPoolExecutor(max_workers=args.workers) as executor:
204
+ futures = {
205
+ executor.submit(get_metadata, accession): accession for accession in eligible_accessions
206
+ }
207
+ for future in as_completed(futures):
208
+ accession, status, models = future.result()
209
+ metadata[accession] = models
210
+ statuses[status] += 1
211
+ (metadata_dir / f"{accession}.json").write_text(
212
+ json.dumps(models, indent=2, sort_keys=True) + "\n"
213
+ )
214
+ canonical: dict[str, dict[str, Any]] = {}
215
+ unavailable: list[str] = []
216
+ for accession in eligible_accessions:
217
+ model = select_canonical_alphafold_model(accession, metadata[accession])
218
+ if model is None:
219
+ unavailable.append(accession)
220
+ else:
221
+ canonical[accession] = model
222
+ files: dict[str, dict[str, Any]] = {}
223
+ with ThreadPoolExecutor(max_workers=args.workers) as executor:
224
+ futures = {
225
+ executor.submit(download_structure, accession, model, structures_dir): accession
226
+ for accession, model in canonical.items()
227
+ }
228
+ for future in as_completed(futures):
229
+ accession, file_metadata = future.result()
230
+ files[accession] = file_metadata
231
+ structure_manifest = {
232
+ "schema_version": "mitointeract-pocket-atlas-structures/v1",
233
+ "canonical_selection": ("modelEntityId == AF-{accession}-F1; provider GDM; monomer only"),
234
+ "eligible_accessions": len(eligible_accessions),
235
+ "available_canonical_models": len(canonical),
236
+ "unavailable_accessions": unavailable,
237
+ "files": {key: files[key] for key in sorted(files)},
238
+ "total_bytes": sum(item["bytes"] for item in files.values()),
239
+ }
240
+ structure_path = args.output_dir / "structure_manifest.json"
241
+ structure_path.write_text(json.dumps(structure_manifest, indent=2, sort_keys=True) + "\n")
242
+ report = {
243
+ "schema_version": "mitointeract-pocket-atlas-sources/v1",
244
+ "inputs": {name: sha256_file(path) for name, path in paths.items()},
245
+ "mapping": {
246
+ **mapping_audit,
247
+ "sha256": sha256_file(mapping_path),
248
+ },
249
+ "alphafold_api": {
250
+ "base_url": AFDB_API,
251
+ "status_counts": {str(key): value for key, value in statuses.items()},
252
+ "metadata_accessions": len(metadata),
253
+ "gdm_monomer_entries": sum(len(models) for models in metadata.values()),
254
+ "canonical_models": len(canonical),
255
+ "unavailable_accessions": unavailable,
256
+ },
257
+ "structures": {
258
+ "files": len(files),
259
+ "total_bytes": structure_manifest["total_bytes"],
260
+ "manifest_sha256": sha256_file(structure_path),
261
+ },
262
+ }
263
+ report_path = args.output_dir / "audit.json"
264
+ report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
265
+ print(json.dumps(report, indent=2, sort_keys=True))
266
+
267
+
268
+ if __name__ == "__main__":
269
+ main()
recovery/scripts/run_pocket_atlas_gate.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run the development-only Pocket Atlas residual gate."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import importlib.metadata
8
+ import json
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ from mitointeract_recovery.chimera import (
15
+ LightGBMConfig,
16
+ ResidualVariant,
17
+ cluster_bootstrap_rmse_improvement,
18
+ evaluate_chimera,
19
+ ligand_feature_matrix,
20
+ read_manifest,
21
+ read_mmseqs_clusters,
22
+ sha256_file,
23
+ validate_and_align,
24
+ )
25
+ from mitointeract_recovery.pocket_features import FEATURE_NAMES
26
+ from mitointeract_recovery.pocket_gate import (
27
+ MINIMUM_INCREMENTAL_RMSE_IMPROVEMENT,
28
+ align_structure_features,
29
+ read_mapping,
30
+ )
31
+
32
+
33
+ def read_jsonl(path: Path) -> list[dict[str, Any]]:
34
+ with path.open() as handle:
35
+ return [json.loads(line) for line in handle if line.strip()]
36
+
37
+
38
+ def parse_args() -> argparse.Namespace:
39
+ parser = argparse.ArgumentParser()
40
+ parser.add_argument("--sample", type=Path, required=True)
41
+ parser.add_argument("--manifest", type=Path, required=True)
42
+ parser.add_argument("--embeddings", type=Path, required=True)
43
+ parser.add_argument("--clusters", type=Path, required=True)
44
+ parser.add_argument("--protein-mapping", type=Path, required=True)
45
+ parser.add_argument("--pocket-features", type=Path, required=True)
46
+ parser.add_argument("--output-dir", type=Path, required=True)
47
+ parser.add_argument("--target-key", default="pkd")
48
+ parser.add_argument("--outer-splits", type=int, default=5)
49
+ parser.add_argument("--control-crossfit-splits", type=int, default=4)
50
+ parser.add_argument("--residual-selection-splits", type=int, default=3)
51
+ parser.add_argument("--bootstrap-iterations", type=int, default=2000)
52
+ parser.add_argument("--seed", type=int, default=42)
53
+ parser.add_argument("--minimum-rmse-improvement", type=float, default=0.05)
54
+ parser.add_argument("--catastrophic-fold-tolerance", type=float, default=0.10)
55
+ parser.add_argument(
56
+ "--minimum-incremental-rmse-improvement",
57
+ type=float,
58
+ default=MINIMUM_INCREMENTAL_RMSE_IMPROVEMENT,
59
+ )
60
+ return parser.parse_args()
61
+
62
+
63
+ def main() -> None:
64
+ args = parse_args()
65
+ inputs = (
66
+ args.sample,
67
+ args.manifest,
68
+ args.embeddings,
69
+ args.clusters,
70
+ args.protein_mapping,
71
+ args.pocket_features,
72
+ )
73
+ for path in inputs:
74
+ if not path.is_file():
75
+ raise FileNotFoundError(path)
76
+ if args.output_dir.exists():
77
+ raise FileExistsError(args.output_dir)
78
+ args.output_dir.mkdir(parents=True)
79
+ started = time.monotonic()
80
+ rows = read_jsonl(args.sample)
81
+ manifest = read_manifest(args.manifest)
82
+ clusters = read_mmseqs_clusters(args.clusters)
83
+ with np.load(args.embeddings) as embedding_arrays:
84
+ aligned = validate_and_align(
85
+ rows,
86
+ embedding_arrays,
87
+ manifest,
88
+ clusters,
89
+ target_key=args.target_key,
90
+ )
91
+ test_clusters = set(aligned["cluster_ids"][aligned["test_indices"]].tolist())
92
+ safe_indices = np.asarray(
93
+ [
94
+ index
95
+ for index in aligned["development_indices"]
96
+ if aligned["cluster_ids"][index] not in test_clusters
97
+ ],
98
+ dtype=np.int64,
99
+ )
100
+ excluded_homologous_development = len(aligned["development_indices"]) - len(safe_indices)
101
+ if set(aligned["cluster_ids"][safe_indices]).intersection(test_clusters):
102
+ raise RuntimeError("test-homologous protein cluster survived the safety filter")
103
+ mapping = read_mapping(args.protein_mapping)
104
+ feature_document = json.loads(args.pocket_features.read_text())
105
+ structure_features, structure_coverage = align_structure_features(
106
+ protein_ids=aligned["protein_ids"],
107
+ safe_indices=safe_indices,
108
+ mapping=mapping,
109
+ feature_document=feature_document,
110
+ )
111
+ ligand_control = ligand_feature_matrix(rows)
112
+ variants = [
113
+ ResidualVariant("protein_embedding_ridge_residual", aligned["protein"]),
114
+ ResidualVariant("structure_ridge_residual", structure_features),
115
+ ResidualVariant(
116
+ "protein_embedding_plus_structure_ridge_residual",
117
+ np.concatenate([aligned["protein"], structure_features], axis=1),
118
+ ),
119
+ ]
120
+ result = evaluate_chimera(
121
+ targets=aligned["targets"],
122
+ ligand_control_features=ligand_control,
123
+ variants=variants,
124
+ development_indices=safe_indices,
125
+ cluster_ids=aligned["cluster_ids"],
126
+ protein_ids=aligned["protein_ids"],
127
+ outer_splits=args.outer_splits,
128
+ control_crossfit_splits=args.control_crossfit_splits,
129
+ residual_selection_splits=args.residual_selection_splits,
130
+ bootstrap_iterations=args.bootstrap_iterations,
131
+ seed=args.seed,
132
+ minimum_rmse_improvement=args.minimum_rmse_improvement,
133
+ catastrophic_fold_tolerance=args.catastrophic_fold_tolerance,
134
+ lightgbm_config=LightGBMConfig(),
135
+ )
136
+ variant_reports = result["variants"]
137
+ esm_name = "protein_embedding_ridge_residual"
138
+ structure_names = (
139
+ "structure_ridge_residual",
140
+ "protein_embedding_plus_structure_ridge_residual",
141
+ )
142
+ best_structure = min(structure_names, key=lambda name: variant_reports[name]["metrics"]["rmse"])
143
+ safe_targets = aligned["targets"][safe_indices]
144
+ safe_clusters = aligned["cluster_ids"][safe_indices]
145
+ esm_predictions = result["variant_predictions"][esm_name][safe_indices]
146
+ structure_predictions = result["variant_predictions"][best_structure][safe_indices]
147
+ incremental_bootstrap = cluster_bootstrap_rmse_improvement(
148
+ safe_targets,
149
+ esm_predictions,
150
+ structure_predictions,
151
+ safe_clusters,
152
+ iterations=args.bootstrap_iterations,
153
+ seed=args.seed + 1,
154
+ )
155
+ incremental_improvement = (
156
+ variant_reports[esm_name]["metrics"]["rmse"]
157
+ - variant_reports[best_structure]["metrics"]["rmse"]
158
+ )
159
+ structure_gate = {
160
+ "best_structure_variant": best_structure,
161
+ "minimum_incremental_rmse_improvement_over_esm": args.minimum_incremental_rmse_improvement,
162
+ "actual_incremental_rmse_improvement_over_esm": incremental_improvement,
163
+ "meets_incremental_improvement": incremental_improvement
164
+ >= args.minimum_incremental_rmse_improvement,
165
+ "incremental_bootstrap": incremental_bootstrap,
166
+ "incremental_bootstrap_lower_bound_positive": incremental_bootstrap["ci_2_5"] > 0,
167
+ "base_gate_passed": variant_reports[best_structure]["gate"]["passed"],
168
+ }
169
+ structure_gate["passed"] = all(
170
+ (
171
+ structure_gate["base_gate_passed"],
172
+ structure_gate["meets_incremental_improvement"],
173
+ structure_gate["incremental_bootstrap_lower_bound_positive"],
174
+ )
175
+ )
176
+
177
+ prediction_path = args.output_dir / "predictions.jsonl"
178
+ with prediction_path.open("w") as handle:
179
+ for index in safe_indices:
180
+ handle.write(
181
+ json.dumps(
182
+ {
183
+ "observation_id": str(aligned["observation_ids"][index]),
184
+ "pair_id": str(aligned["pair_ids"][index]),
185
+ "protein_id": str(aligned["protein_ids"][index]),
186
+ "protein_cluster_id": str(aligned["cluster_ids"][index]),
187
+ "original_split": str(aligned["row_splits"][index]),
188
+ "development_fold": int(result["fold_assignments"][index]),
189
+ "structure_available": bool(structure_features[index, 0]),
190
+ "target_pkd": float(aligned["targets"][index]),
191
+ "ligand_control_prediction": float(result["control_predictions"][index]),
192
+ "variant_predictions": {
193
+ name: float(predictions[index])
194
+ for name, predictions in result["variant_predictions"].items()
195
+ },
196
+ },
197
+ sort_keys=True,
198
+ )
199
+ + "\n"
200
+ )
201
+ report = {
202
+ "schema_version": "mitointeract-pocket-atlas-gate/v1",
203
+ "experiment": "pocket_atlas_development_gate",
204
+ "status": "development_only_no_test_or_external_evaluation",
205
+ "decision": (
206
+ "advance_structure_candidate"
207
+ if structure_gate["passed"]
208
+ else "reject_structure_candidate"
209
+ ),
210
+ "target": "pKd",
211
+ "seed": args.seed,
212
+ "benchmark_test_evaluations": 0,
213
+ "external_holdout_evaluations": 0,
214
+ "population": {
215
+ "sample_rows": len(rows),
216
+ "source_development_rows": len(aligned["development_indices"]),
217
+ "development_rows_excluded_for_test_cluster_homology": excluded_homologous_development,
218
+ "safe_development_rows": len(safe_indices),
219
+ "safe_development_pairs": len(set(aligned["pair_ids"][safe_indices].tolist())),
220
+ "safe_development_proteins": len(set(aligned["protein_ids"][safe_indices].tolist())),
221
+ "safe_development_clusters": result["development_clusters"],
222
+ "benchmark_test_rows_excluded": len(aligned["test_indices"]),
223
+ "benchmark_test_clusters_reserved": len(test_clusters),
224
+ },
225
+ "structure_coverage": structure_coverage,
226
+ "inputs": {path.stem + "_sha256": sha256_file(path) for path in inputs},
227
+ "features": {
228
+ "pocket_feature_count": len(FEATURE_NAMES),
229
+ "mapping_quality_feature_count": 2,
230
+ "total_structure_feature_count": structure_features.shape[1],
231
+ "pocket_feature_names": FEATURE_NAMES,
232
+ "mapping_quality_features": ["uniprot_sequence_coverage", "exact_sequence_mapping"],
233
+ },
234
+ "control": {
235
+ "name": "ligand_morgan_descriptors_lightgbm",
236
+ **result["control"],
237
+ },
238
+ "variants": variant_reports,
239
+ "structure_gate": structure_gate,
240
+ "folds": result["folds"],
241
+ "protocol": {
242
+ "outer_splits": args.outer_splits,
243
+ "outer_group": "Source Atlas joint MMseqs2 50/80 protein cluster",
244
+ "control_crossfit_splits": args.control_crossfit_splits,
245
+ "residual_selection_splits": args.residual_selection_splits,
246
+ "bootstrap_iterations": args.bootstrap_iterations,
247
+ "bootstrap_unit": "Source Atlas MMseqs2 protein cluster",
248
+ "minimum_control_rmse_improvement": args.minimum_rmse_improvement,
249
+ "minimum_incremental_rmse_improvement_over_esm": (
250
+ args.minimum_incremental_rmse_improvement
251
+ ),
252
+ "catastrophic_fold_tolerance": args.catastrophic_fold_tolerance,
253
+ "benchmark_test_policy": (
254
+ "masked and excluded with every homologous development cluster"
255
+ ),
256
+ "external_holdout_policy": "not loaded, not predicted, and not evaluated",
257
+ },
258
+ "packages": {
259
+ package: importlib.metadata.version(package)
260
+ for package in ("lightgbm", "numpy", "rdkit", "scikit-learn")
261
+ },
262
+ "artifacts": {
263
+ "predictions": prediction_path.name,
264
+ "predictions_sha256": sha256_file(prediction_path),
265
+ },
266
+ "elapsed_seconds": time.monotonic() - started,
267
+ }
268
+ report_path = args.output_dir / "report.json"
269
+ report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
270
+ print(
271
+ json.dumps(
272
+ {
273
+ "decision": report["decision"],
274
+ "control_rmse": report["control"]["metrics"]["rmse"],
275
+ "esm_rmse": report["variants"][esm_name]["metrics"]["rmse"],
276
+ "best_structure_variant": best_structure,
277
+ "best_structure_rmse": report["variants"][best_structure]["metrics"]["rmse"],
278
+ "structure_gate": structure_gate,
279
+ "benchmark_test_evaluations": 0,
280
+ "external_holdout_evaluations": 0,
281
+ "report": str(report_path),
282
+ },
283
+ indent=2,
284
+ )
285
+ )
286
+
287
+
288
+ if __name__ == "__main__":
289
+ main()
recovery/src/mitointeract_recovery/pocket_features.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic AlphaFold confidence and alpha-sphere pocket descriptors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ from scipy.spatial.distance import pdist # type: ignore[import-not-found]
11
+
12
+ POCKET_TOOL = "pocketeer"
13
+ POCKET_TOOL_VERSION = "0.3.1"
14
+ TOP_POCKETS = 3
15
+ HYDROPHOBIC = frozenset({"ALA", "VAL", "ILE", "LEU", "MET", "PHE", "TRP", "PRO"})
16
+ AROMATIC = frozenset({"PHE", "TYR", "TRP", "HIS"})
17
+ CHARGED = frozenset({"ASP", "GLU", "LYS", "ARG", "HIS"})
18
+ POLAR = frozenset({"SER", "THR", "ASN", "GLN", "CYS", "TYR", "HIS"})
19
+
20
+ GLOBAL_FEATURES = (
21
+ "structure_available",
22
+ "af_global_metric_100",
23
+ "af_fraction_very_high",
24
+ "af_fraction_confident",
25
+ "af_fraction_low",
26
+ "af_fraction_very_low",
27
+ "log_sequence_length",
28
+ "log_atom_count",
29
+ "log_ca_count",
30
+ "radius_gyration_100",
31
+ "bbox_small_100",
32
+ "bbox_middle_100",
33
+ "bbox_large_100",
34
+ "log_bbox_volume",
35
+ "shape_eigenvalue_1",
36
+ "shape_eigenvalue_2",
37
+ "shape_eigenvalue_3",
38
+ "ca_contact_density_8",
39
+ "ca_contact_density_12",
40
+ "plddt_mean_100",
41
+ "plddt_std_100",
42
+ "plddt_q10_100",
43
+ "plddt_q50_100",
44
+ "plddt_q90_100",
45
+ "plddt_fraction_ge70",
46
+ "plddt_fraction_ge90",
47
+ "log_pocket_count",
48
+ "pocket_score_max",
49
+ "pocket_score_mean",
50
+ "log_pocket_volume_sum",
51
+ "log_pocket_residue_sum",
52
+ )
53
+ POCKET_FEATURE_SUFFIXES = (
54
+ "score",
55
+ "log_volume",
56
+ "log_spheres",
57
+ "log_residues",
58
+ "mean_radius_10",
59
+ "mean_sasa_100",
60
+ "plddt_mean_100",
61
+ "hydrophobic_fraction",
62
+ "aromatic_fraction",
63
+ "charged_fraction",
64
+ "polar_fraction",
65
+ )
66
+ FEATURE_NAMES = GLOBAL_FEATURES + tuple(
67
+ f"pocket_{rank}_{suffix}"
68
+ for rank in range(1, TOP_POCKETS + 1)
69
+ for suffix in POCKET_FEATURE_SUFFIXES
70
+ )
71
+
72
+
73
+ def parse_pdb_plddt(path: Path) -> np.ndarray:
74
+ """Read AlphaFold per-atom pLDDT from PDB B-factor columns."""
75
+ values: list[float] = []
76
+ with path.open() as handle:
77
+ for line_number, line in enumerate(handle, start=1):
78
+ if not line.startswith(("ATOM ", "HETATM")):
79
+ continue
80
+ if len(line) < 66:
81
+ raise ValueError(f"PDB line {line_number}: missing B-factor columns")
82
+ try:
83
+ value = float(line[60:66])
84
+ except ValueError as exc:
85
+ raise ValueError(f"PDB line {line_number}: invalid B-factor") from exc
86
+ if not math.isfinite(value) or not 0 <= value <= 100:
87
+ raise ValueError(f"PDB line {line_number}: invalid AlphaFold pLDDT")
88
+ values.append(value)
89
+ if not values:
90
+ raise ValueError("PDB contains no coordinate records")
91
+ return np.asarray(values, dtype=np.float64)
92
+
93
+
94
+ def _fraction(residues: list[tuple[str, int, str]], group: frozenset[str]) -> float:
95
+ if not residues:
96
+ return 0.0
97
+ return sum(residue_name in group for _, _, residue_name in residues) / len(residues)
98
+
99
+
100
+ def _pocket_features(pocket: Any, plddt: np.ndarray) -> list[float]:
101
+ radii = np.asarray([sphere.radius for sphere in pocket.spheres], dtype=np.float64)
102
+ sasa = np.asarray([sphere.mean_sasa for sphere in pocket.spheres], dtype=np.float64)
103
+ pocket_confidence = plddt[np.asarray(pocket.mask, dtype=bool)]
104
+ residues = list(pocket.residues)
105
+ return [
106
+ float(pocket.score),
107
+ math.log1p(max(0.0, float(pocket.volume))),
108
+ math.log1p(int(pocket.n_spheres)),
109
+ math.log1p(len(residues)),
110
+ float(np.mean(radii)) / 10.0,
111
+ float(np.mean(sasa)) / 100.0,
112
+ float(np.mean(pocket_confidence)) / 100.0,
113
+ _fraction(residues, HYDROPHOBIC),
114
+ _fraction(residues, AROMATIC),
115
+ _fraction(residues, CHARGED),
116
+ _fraction(residues, POLAR),
117
+ ]
118
+
119
+
120
+ def structure_feature_vector(
121
+ atomarray: Any,
122
+ pockets: list[Any],
123
+ plddt: np.ndarray,
124
+ metadata: dict[str, Any],
125
+ ) -> np.ndarray:
126
+ """Build fixed-width, rotation-invariant global and top-pocket features."""
127
+ coordinates = np.asarray(atomarray.coord, dtype=np.float64)
128
+ if coordinates.ndim != 2 or coordinates.shape[1] != 3:
129
+ raise ValueError("atom coordinates must have shape [N, 3]")
130
+ if plddt.shape != (len(atomarray),):
131
+ raise ValueError(f"pLDDT/atom count mismatch: {len(plddt)} vs {len(atomarray)}")
132
+ ca_mask = np.asarray(atomarray.atom_name == "CA", dtype=bool)
133
+ ca = coordinates[ca_mask]
134
+ if len(ca) < 2:
135
+ raise ValueError("structure has fewer than two CA atoms")
136
+ centered = ca - np.mean(ca, axis=0)
137
+ radius_gyration = float(np.sqrt(np.mean(np.sum(centered**2, axis=1))))
138
+ covariance = np.cov(centered, rowvar=False)
139
+ raw_eigenvalues, eigenvectors = np.linalg.eigh(covariance)
140
+ eigenvalues = np.maximum(raw_eigenvalues, 0.0)[::-1]
141
+ principal_coordinates = centered @ eigenvectors[:, ::-1]
142
+ bounds = np.sort(np.ptp(principal_coordinates, axis=0))
143
+ eigenvalue_total = float(np.sum(eigenvalues))
144
+ normalized_eigenvalues = eigenvalues / eigenvalue_total if eigenvalue_total > 0 else np.zeros(3)
145
+ distances = pdist(ca)
146
+ contact_8 = float(np.mean(distances <= 8.0))
147
+ contact_12 = float(np.mean(distances <= 12.0))
148
+ sorted_pockets = sorted(pockets, key=lambda pocket: pocket.score, reverse=True)
149
+ scores = np.asarray([pocket.score for pocket in sorted_pockets], dtype=np.float64)
150
+ volumes = np.asarray([pocket.volume for pocket in sorted_pockets], dtype=np.float64)
151
+ residue_counts = np.asarray(
152
+ [len(pocket.residues) for pocket in sorted_pockets], dtype=np.float64
153
+ )
154
+ plddt_quantiles = np.quantile(plddt, [0.1, 0.5, 0.9])
155
+ global_values = [
156
+ 1.0,
157
+ float(metadata["global_metric_value"]) / 100.0,
158
+ float(metadata["fraction_plddt_very_high"]),
159
+ float(metadata["fraction_plddt_confident"]),
160
+ float(metadata["fraction_plddt_low"]),
161
+ float(metadata["fraction_plddt_very_low"]),
162
+ math.log1p(int(metadata["sequence_end"]) - int(metadata["sequence_start"]) + 1),
163
+ math.log1p(len(atomarray)),
164
+ math.log1p(len(ca)),
165
+ radius_gyration / 100.0,
166
+ *(bounds / 100.0).tolist(),
167
+ math.log1p(float(np.prod(bounds))),
168
+ *normalized_eigenvalues.tolist(),
169
+ contact_8,
170
+ contact_12,
171
+ float(np.mean(plddt)) / 100.0,
172
+ float(np.std(plddt)) / 100.0,
173
+ *(plddt_quantiles / 100.0).tolist(),
174
+ float(np.mean(plddt >= 70.0)),
175
+ float(np.mean(plddt >= 90.0)),
176
+ math.log1p(len(sorted_pockets)),
177
+ float(np.max(scores)) if len(scores) else 0.0,
178
+ float(np.mean(scores)) if len(scores) else 0.0,
179
+ math.log1p(float(np.sum(volumes))) if len(volumes) else 0.0,
180
+ math.log1p(float(np.sum(residue_counts))) if len(residue_counts) else 0.0,
181
+ ]
182
+ values = global_values
183
+ for rank in range(TOP_POCKETS):
184
+ if rank < len(sorted_pockets):
185
+ values.extend(_pocket_features(sorted_pockets[rank], plddt))
186
+ else:
187
+ values.extend([0.0] * len(POCKET_FEATURE_SUFFIXES))
188
+ vector = np.asarray(values, dtype=np.float32)
189
+ if vector.shape != (len(FEATURE_NAMES),):
190
+ raise RuntimeError(f"feature width mismatch: {vector.shape} vs {len(FEATURE_NAMES)}")
191
+ if not np.isfinite(vector).all():
192
+ raise ValueError("structure feature vector contains nonfinite values")
193
+ return vector
194
+
195
+
196
+ def extract_structure_features(
197
+ pdb_path: Path, metadata: dict[str, Any]
198
+ ) -> tuple[np.ndarray, dict[str, int]]:
199
+ """Load one structure and run the pinned optional pocket detector."""
200
+ import importlib.metadata
201
+
202
+ import pocketeer # type: ignore[import-not-found]
203
+
204
+ version = importlib.metadata.version(POCKET_TOOL)
205
+ if version != POCKET_TOOL_VERSION:
206
+ raise ValueError(
207
+ f"unexpected {POCKET_TOOL} version: {version}; expected {POCKET_TOOL_VERSION}"
208
+ )
209
+ atomarray = pocketeer.load_structure(str(pdb_path))
210
+ plddt = parse_pdb_plddt(pdb_path)
211
+ pockets = pocketeer.find_pockets(atomarray)
212
+ return structure_feature_vector(atomarray, pockets, plddt, metadata), {
213
+ "atoms": len(atomarray),
214
+ "ca_atoms": int(np.sum(atomarray.atom_name == "CA")),
215
+ "pockets": len(pockets),
216
+ }
recovery/src/mitointeract_recovery/pocket_gate.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validation and alignment helpers for the Pocket Atlas development gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+ from mitointeract_recovery.pocket_features import FEATURE_NAMES
12
+
13
+ FEATURE_SCHEMA = "mitointeract-pocket-atlas-features/v1"
14
+ MINIMUM_INCREMENTAL_RMSE_IMPROVEMENT = 0.02
15
+
16
+
17
+ def read_mapping(path: Path) -> dict[str, dict[str, Any]]:
18
+ mapping: dict[str, dict[str, Any]] = {}
19
+ with path.open() as handle:
20
+ for row_number, line in enumerate(handle, start=1):
21
+ if not line.strip():
22
+ continue
23
+ row = json.loads(line)
24
+ protein_id = str(row.get("protein_id", "")).strip()
25
+ if not protein_id or protein_id in mapping:
26
+ raise ValueError(f"invalid or duplicate protein mapping row {row_number}")
27
+ mapping[protein_id] = row
28
+ if not mapping:
29
+ raise ValueError("protein mapping is empty")
30
+ return mapping
31
+
32
+
33
+ def align_structure_features(
34
+ *,
35
+ protein_ids: np.ndarray,
36
+ safe_indices: np.ndarray,
37
+ mapping: dict[str, dict[str, Any]],
38
+ feature_document: dict[str, Any],
39
+ ) -> tuple[np.ndarray, dict[str, int]]:
40
+ """Align accession-level descriptors to observations with fail-closed coverage."""
41
+ if feature_document.get("schema_version") != FEATURE_SCHEMA:
42
+ raise ValueError("unexpected Pocket Atlas feature schema")
43
+ if tuple(feature_document.get("feature_names", ())) != FEATURE_NAMES:
44
+ raise ValueError("Pocket Atlas feature names differ from the code contract")
45
+ accessions = feature_document.get("accessions")
46
+ if not isinstance(accessions, dict):
47
+ raise ValueError("Pocket Atlas feature document has no accession map")
48
+ safe_proteins = set(protein_ids[safe_indices].tolist())
49
+ if set(mapping) != safe_proteins:
50
+ raise ValueError("protein mapping must exactly cover homology-safe proteins")
51
+ matrix = np.zeros((len(protein_ids), len(FEATURE_NAMES) + 2), dtype=np.float32)
52
+ available_proteins: set[str] = set()
53
+ for protein_id in safe_proteins:
54
+ row = mapping[protein_id]
55
+ accession = str(row.get("accession", ""))
56
+ if bool(row.get("eligible")) and accession in accessions:
57
+ vector = np.asarray(accessions[accession].get("features"), dtype=np.float32)
58
+ if vector.shape != (len(FEATURE_NAMES),) or not np.isfinite(vector).all():
59
+ raise ValueError(f"invalid Pocket Atlas vector for {accession}")
60
+ if vector[0] != 1.0:
61
+ raise ValueError(
62
+ f"available Pocket Atlas vector lacks availability flag: {accession}"
63
+ )
64
+ coverage = float(row.get("coverage", float("nan")))
65
+ if not np.isfinite(coverage) or not 0.8 <= coverage <= 1.0:
66
+ raise ValueError(f"invalid eligible mapping coverage for {protein_id}")
67
+ protein_rows = np.flatnonzero(protein_ids == protein_id)
68
+ matrix[protein_rows, : len(FEATURE_NAMES)] = vector
69
+ matrix[protein_rows, -2] = coverage
70
+ matrix[protein_rows, -1] = float(row.get("placement") == "exact")
71
+ available_proteins.add(protein_id)
72
+ if not np.isfinite(matrix).all():
73
+ raise ValueError("aligned Pocket Atlas matrix contains nonfinite values")
74
+ safe_available = matrix[safe_indices, 0] == 1.0
75
+ return matrix, {
76
+ "safe_rows": len(safe_indices),
77
+ "available_rows": int(np.sum(safe_available)),
78
+ "missing_rows": int(np.sum(~safe_available)),
79
+ "safe_proteins": len(safe_proteins),
80
+ "available_proteins": len(available_proteins),
81
+ "missing_proteins": len(safe_proteins.difference(available_proteins)),
82
+ }
recovery/src/mitointeract_recovery/pocket_sources.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the development-only benchmark-to-UniProt structure mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from typing import Any
7
+
8
+
9
+ def place_sequence(query: str, reference: str) -> dict[str, Any]:
10
+ """Place a benchmark sequence in a UniProt sequence without alignment guessing."""
11
+ if not query or not reference:
12
+ return {
13
+ "placement": "unmapped",
14
+ "coverage": 0.0,
15
+ "sequence_start": None,
16
+ "sequence_end": None,
17
+ }
18
+ if query == reference:
19
+ return {
20
+ "placement": "exact",
21
+ "coverage": 1.0,
22
+ "sequence_start": 1,
23
+ "sequence_end": len(reference),
24
+ }
25
+ if query in reference:
26
+ start = reference.index(query) + 1
27
+ return {
28
+ "placement": "subsequence",
29
+ "coverage": len(query) / len(reference),
30
+ "sequence_start": start,
31
+ "sequence_end": start + len(query) - 1,
32
+ }
33
+ if reference in query:
34
+ return {
35
+ "placement": "supersequence",
36
+ "coverage": len(reference) / len(query),
37
+ "sequence_start": 1,
38
+ "sequence_end": len(reference),
39
+ }
40
+ return {
41
+ "placement": "noncontiguous",
42
+ "coverage": 0.0,
43
+ "sequence_start": None,
44
+ "sequence_end": None,
45
+ }
46
+
47
+
48
+ def select_canonical_alphafold_model(
49
+ accession: str, models: list[dict[str, Any]]
50
+ ) -> dict[str, Any] | None:
51
+ """Select only the canonical DeepMind monomer, never an isoform or complex."""
52
+ expected_id = f"AF-{accession}-F1"
53
+ matches = [
54
+ model
55
+ for model in models
56
+ if model.get("modelEntityId") == expected_id
57
+ and model.get("providerId") == "GDM"
58
+ and model.get("entityType") == "protein"
59
+ and not model.get("isComplex", False)
60
+ ]
61
+ if not matches:
62
+ return None
63
+ if len(matches) != 1:
64
+ raise ValueError(f"multiple canonical AlphaFold models for {accession}")
65
+ model = matches[0]
66
+ if not model.get("pdbUrl") or not model.get("latestVersion"):
67
+ raise ValueError(f"canonical AlphaFold model lacks versioned coordinates: {accession}")
68
+ return model
69
+
70
+
71
+ def build_protein_mapping(
72
+ *,
73
+ sample_rows: list[dict[str, Any]],
74
+ manifest: dict[str, str],
75
+ source_accessions: dict[str, str],
76
+ clusters: dict[str, str],
77
+ uniprot_entries: dict[str, dict[str, Any]],
78
+ minimum_coverage: float = 0.8,
79
+ ) -> tuple[list[dict[str, Any]], dict[str, int]]:
80
+ """Map homology-safe development proteins to reviewed UniProt sequences."""
81
+ if not 0 < minimum_coverage <= 1:
82
+ raise ValueError("minimum coverage must be in (0, 1]")
83
+ pair_ids = {str(row.get("pair_id", "")) for row in sample_rows}
84
+ if set(manifest) != pair_ids:
85
+ raise ValueError("manifest pair set must exactly match the sample")
86
+ missing_clusters = {str(row.get("protein_id", "")) for row in sample_rows}.difference(clusters)
87
+ if missing_clusters:
88
+ raise ValueError(
89
+ f"sample proteins missing cluster assignments: {sorted(missing_clusters)[:3]}"
90
+ )
91
+ test_clusters = {
92
+ clusters[str(row["protein_id"])]
93
+ for row in sample_rows
94
+ if manifest[str(row["pair_id"])] == "test"
95
+ }
96
+ safe_rows = [
97
+ row
98
+ for row in sample_rows
99
+ if manifest[str(row["pair_id"])] in {"train", "validation"}
100
+ and clusters[str(row["protein_id"])] not in test_clusters
101
+ ]
102
+ if not safe_rows:
103
+ raise ValueError("homology-safe development population is empty")
104
+ accessions_by_protein: dict[str, set[str]] = defaultdict(set)
105
+ sequence_by_protein: dict[str, str] = {}
106
+ for row in safe_rows:
107
+ protein_id = str(row["protein_id"])
108
+ sequence = str(row["sequence"])
109
+ previous = sequence_by_protein.setdefault(protein_id, sequence)
110
+ if previous != sequence:
111
+ raise ValueError(f"inconsistent sequence for protein {protein_id}")
112
+ for source_record_id in row.get("source_record_ids", ()):
113
+ accession = source_accessions.get(str(source_record_id), "").strip()
114
+ if accession:
115
+ accessions_by_protein[protein_id].add(accession)
116
+ output: list[dict[str, Any]] = []
117
+ reason_counts: dict[str, int] = defaultdict(int)
118
+ for protein_id in sorted(sequence_by_protein):
119
+ accessions = sorted(accessions_by_protein[protein_id])
120
+ record: dict[str, Any] = {
121
+ "protein_id": protein_id,
122
+ "sequence": sequence_by_protein[protein_id],
123
+ "cluster": clusters[protein_id],
124
+ "accessions": accessions,
125
+ "eligible": False,
126
+ }
127
+ if len(accessions) != 1:
128
+ reason = "missing_accession" if not accessions else "ambiguous_accession"
129
+ reason_counts[reason] += 1
130
+ output.append(record)
131
+ continue
132
+ accession = accessions[0]
133
+ entry = uniprot_entries.get(accession)
134
+ if entry is None:
135
+ reason_counts["missing_uniprot_entry"] += 1
136
+ output.append(record)
137
+ continue
138
+ if str(entry.get("reviewed")) != "reviewed":
139
+ reason_counts["unreviewed_uniprot_entry"] += 1
140
+ output.append(record)
141
+ continue
142
+ reference = str(entry["sequence"])
143
+ placement = place_sequence(record["sequence"], reference)
144
+ record.update(
145
+ {
146
+ "accession": accession,
147
+ "uniprot_sequence": reference,
148
+ "placement": placement["placement"],
149
+ "coverage": placement["coverage"],
150
+ "sequence_start": placement["sequence_start"],
151
+ "sequence_end": placement["sequence_end"],
152
+ "uniprot_sequence_version": int(entry["sequence_version"]),
153
+ }
154
+ )
155
+ record["eligible"] = (
156
+ placement["placement"] in {"exact", "subsequence", "supersequence"}
157
+ and placement["coverage"] >= minimum_coverage
158
+ )
159
+ if not record["eligible"]:
160
+ reason_counts["sequence_placement_or_coverage"] += 1
161
+ output.append(record)
162
+ audit = {
163
+ "safe_rows": len(safe_rows),
164
+ "safe_proteins": len(output),
165
+ "test_clusters_reserved": len(test_clusters),
166
+ "eligible_proteins": sum(bool(row["eligible"]) for row in output),
167
+ "eligible_accessions": len({str(row["accession"]) for row in output if row["eligible"]}),
168
+ **{f"ineligible_{key}": value for key, value in sorted(reason_counts.items())},
169
+ }
170
+ return output, audit
recovery/tests/test_checked_reports.py CHANGED
@@ -211,6 +211,49 @@ def test_gtopdb_external_holdout_is_independent_cold_both_and_sealed():
211
  assert report["evaluation_policy"]["status"] == "sealed"
212
 
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  def test_primary_kd_audit_and_similarity_split():
215
  audit = load("balm-kd-audit.json")
216
  clusters = load("balm-kd-protein-clusters.json")
 
211
  assert report["evaluation_policy"]["status"] == "sealed"
212
 
213
 
214
+ def test_pocket_atlas_gate_rejects_structure_without_touching_holdouts():
215
+ report = load("bindingdb-v2-pocket-atlas-development-gate.json")
216
+ assert report["decision"] == "reject_structure_candidate_no_further_training"
217
+ assert report["benchmark_test_evaluations"] == 0
218
+ assert report["external_holdout_evaluations"] == 0
219
+ assert report["population"] == {
220
+ "sample_rows": 2327,
221
+ "source_development_rows": 2057,
222
+ "development_rows_excluded_for_test_cluster_homology": 292,
223
+ "safe_development_rows": 1765,
224
+ "safe_development_pairs": 1717,
225
+ "safe_development_proteins": 278,
226
+ "safe_development_clusters": 187,
227
+ "benchmark_test_rows_excluded": 270,
228
+ "benchmark_test_clusters_reserved": 34,
229
+ }
230
+ assert report["alphafold_snapshot"]["canonical_models"] == 145
231
+ assert report["structure_features"]["successful_structures"] == 145
232
+ assert report["structure_features"]["failed_structures"] == 0
233
+ control = report["control"]["metrics"]["rmse"]
234
+ esm = report["variants"]["protein_embedding_ridge_residual"]["metrics"]["rmse"]
235
+ structure = report["variants"]["structure_ridge_residual"]["metrics"]["rmse"]
236
+ combined = report["variants"]["protein_embedding_plus_structure_ridge_residual"]["metrics"][
237
+ "rmse"
238
+ ]
239
+ assert esm < combined < control < structure
240
+ gate = report["predeclared_structure_gate"]
241
+ assert gate["actual_incremental_rmse_improvement_vs_esm"] < 0
242
+ assert gate["incremental_cluster_bootstrap"]["ci_2_5"] < 0
243
+ assert gate["incremental_cluster_bootstrap"]["ci_97_5"] > 0
244
+ assert gate["no_catastrophic_fold"] is False
245
+ assert gate["passed"] is False
246
+ verification = report["verification"]
247
+ assert verification["test_pairs_in_predictions"] == 0
248
+ assert verification["test_clusters_in_predictions"] == 0
249
+ assert verification["source_reproduction"] == {
250
+ "mapping_exact_match": True,
251
+ "structure_manifest_exact_match": True,
252
+ }
253
+ assert report["artifacts"]["checkpoint_created"] is False
254
+ assert report["artifacts"]["weights_published"] is False
255
+
256
+
257
  def test_primary_kd_audit_and_similarity_split():
258
  audit = load("balm-kd-audit.json")
259
  clusters = load("balm-kd-protein-clusters.json")
recovery/tests/test_pocket_features.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for deterministic Pocket Atlas structural descriptors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import pytest
10
+ from mitointeract_recovery.pocket_features import (
11
+ FEATURE_NAMES,
12
+ parse_pdb_plddt,
13
+ structure_feature_vector,
14
+ )
15
+
16
+
17
+ class FakeAtoms:
18
+ def __init__(self, coordinates: np.ndarray, atom_names: list[str]) -> None:
19
+ self.coord = coordinates
20
+ self.atom_name = np.asarray(atom_names)
21
+
22
+ def __len__(self) -> int:
23
+ return len(self.coord)
24
+
25
+
26
+ @dataclass
27
+ class FakeSphere:
28
+ radius: float
29
+ mean_sasa: float
30
+
31
+
32
+ @dataclass
33
+ class FakePocket:
34
+ score: float
35
+ volume: float
36
+ spheres: list[FakeSphere]
37
+ residues: list[tuple[str, int, str]]
38
+ mask: np.ndarray
39
+
40
+ @property
41
+ def n_spheres(self) -> int:
42
+ return len(self.spheres)
43
+
44
+
45
+ def pdb_atom(serial: int, plddt: float) -> str:
46
+ return (
47
+ f"ATOM {serial:5d} CA ALA A{serial:4d} "
48
+ f"{serial:8.3f}{0.0:8.3f}{0.0:8.3f}{1.0:6.2f}{plddt:6.2f} C\n"
49
+ )
50
+
51
+
52
+ def metadata() -> dict:
53
+ return {
54
+ "global_metric_value": 85.0,
55
+ "fraction_plddt_very_high": 0.4,
56
+ "fraction_plddt_confident": 0.4,
57
+ "fraction_plddt_low": 0.1,
58
+ "fraction_plddt_very_low": 0.1,
59
+ "sequence_start": 1,
60
+ "sequence_end": 4,
61
+ }
62
+
63
+
64
+ def test_parse_pdb_plddt_reads_coordinate_b_factors(tmp_path: Path) -> None:
65
+ path = tmp_path / "model.pdb"
66
+ path.write_text(pdb_atom(1, 91.2) + pdb_atom(2, 67.8) + "END\n")
67
+ assert parse_pdb_plddt(path).tolist() == [91.2, 67.8]
68
+
69
+
70
+ def test_parse_pdb_plddt_rejects_out_of_range_confidence(tmp_path: Path) -> None:
71
+ path = tmp_path / "model.pdb"
72
+ path.write_text(pdb_atom(1, 101.0))
73
+ with pytest.raises(ValueError, match="invalid AlphaFold pLDDT"):
74
+ parse_pdb_plddt(path)
75
+
76
+
77
+ def test_structure_features_have_fixed_finite_width() -> None:
78
+ coordinates = np.asarray([[0, 0, 0], [2, 1, 0], [4, 1, 2], [7, 3, 1]], dtype=float)
79
+ atoms = FakeAtoms(coordinates, ["CA"] * 4)
80
+ pocket = FakePocket(
81
+ score=0.8,
82
+ volume=200.0,
83
+ spheres=[FakeSphere(4.0, 12.0), FakeSphere(5.0, 16.0)],
84
+ residues=[("A", 1, "LEU"), ("A", 2, "ASP")],
85
+ mask=np.asarray([True, True, False, False]),
86
+ )
87
+ vector = structure_feature_vector(
88
+ atoms, [pocket], np.asarray([90, 80, 70, 60], dtype=float), metadata()
89
+ )
90
+ assert vector.shape == (len(FEATURE_NAMES),)
91
+ assert len(FEATURE_NAMES) == len(set(FEATURE_NAMES))
92
+ assert np.isfinite(vector).all()
93
+ assert vector[FEATURE_NAMES.index("structure_available")] == 1
94
+ assert vector[FEATURE_NAMES.index("log_pocket_count")] > 0
95
+ assert vector[FEATURE_NAMES.index("pocket_1_hydrophobic_fraction")] == 0.5
96
+
97
+
98
+ def test_structure_features_are_rotation_invariant() -> None:
99
+ coordinates = np.asarray([[0, 0, 0], [2, 1, 0], [4, 1, 2], [7, 3, 1], [8, 6, 3]], dtype=float)
100
+ rotation = np.asarray([[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], dtype=float)
101
+ plddt = np.asarray([90, 80, 70, 60, 50], dtype=float)
102
+ first = structure_feature_vector(FakeAtoms(coordinates, ["CA"] * 5), [], plddt, metadata())
103
+ second = structure_feature_vector(
104
+ FakeAtoms(coordinates @ rotation.T, ["CA"] * 5), [], plddt, metadata()
105
+ )
106
+ np.testing.assert_allclose(first, second, rtol=1e-6, atol=1e-6)
107
+
108
+
109
+ def test_structure_features_reject_plddt_atom_mismatch() -> None:
110
+ atoms = FakeAtoms(np.asarray([[0, 0, 0], [1, 1, 1]], dtype=float), ["CA", "CA"])
111
+ with pytest.raises(ValueError, match="count mismatch"):
112
+ structure_feature_vector(atoms, [], np.asarray([90.0]), metadata())
recovery/tests/test_pocket_gate.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Pocket Atlas feature alignment and missingness handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import pytest
10
+ from mitointeract_recovery.pocket_features import FEATURE_NAMES
11
+ from mitointeract_recovery.pocket_gate import (
12
+ FEATURE_SCHEMA,
13
+ align_structure_features,
14
+ read_mapping,
15
+ )
16
+
17
+
18
+ def mapping() -> dict[str, dict]:
19
+ return {
20
+ "p1": {
21
+ "protein_id": "p1",
22
+ "eligible": True,
23
+ "accession": "A1",
24
+ "coverage": 1.0,
25
+ "placement": "exact",
26
+ },
27
+ "p2": {
28
+ "protein_id": "p2",
29
+ "eligible": True,
30
+ "accession": "A2",
31
+ "coverage": 0.9,
32
+ "placement": "subsequence",
33
+ },
34
+ "p3": {"protein_id": "p3", "eligible": False},
35
+ }
36
+
37
+
38
+ def document() -> dict:
39
+ vector = [0.0] * len(FEATURE_NAMES)
40
+ vector[0] = 1.0
41
+ return {
42
+ "schema_version": FEATURE_SCHEMA,
43
+ "feature_names": FEATURE_NAMES,
44
+ "accessions": {"A1": {"features": vector}},
45
+ }
46
+
47
+
48
+ def test_align_structure_features_tracks_available_and_missing_rows() -> None:
49
+ protein_ids = np.asarray(["p1", "p1", "p2", "p3"])
50
+ matrix, coverage = align_structure_features(
51
+ protein_ids=protein_ids,
52
+ safe_indices=np.arange(4),
53
+ mapping=mapping(),
54
+ feature_document=document(),
55
+ )
56
+ assert matrix.shape == (4, len(FEATURE_NAMES) + 2)
57
+ assert matrix[:, 0].tolist() == [1.0, 1.0, 0.0, 0.0]
58
+ assert matrix[:2, -2].tolist() == [1.0, 1.0]
59
+ assert matrix[:2, -1].tolist() == [1.0, 1.0]
60
+ assert coverage == {
61
+ "safe_rows": 4,
62
+ "available_rows": 2,
63
+ "missing_rows": 2,
64
+ "safe_proteins": 3,
65
+ "available_proteins": 1,
66
+ "missing_proteins": 2,
67
+ }
68
+
69
+
70
+ def test_align_structure_features_requires_exact_safe_protein_manifest() -> None:
71
+ with pytest.raises(ValueError, match="exactly cover"):
72
+ align_structure_features(
73
+ protein_ids=np.asarray(["p1", "p2"]),
74
+ safe_indices=np.arange(2),
75
+ mapping={"p1": mapping()["p1"]},
76
+ feature_document=document(),
77
+ )
78
+
79
+
80
+ def test_align_structure_features_rejects_invalid_availability_flag() -> None:
81
+ bad = document()
82
+ bad["accessions"]["A1"]["features"][0] = 0.0
83
+ with pytest.raises(ValueError, match="availability flag"):
84
+ align_structure_features(
85
+ protein_ids=np.asarray(["p1", "p2", "p3"]),
86
+ safe_indices=np.arange(3),
87
+ mapping=mapping(),
88
+ feature_document=bad,
89
+ )
90
+
91
+
92
+ def test_align_structure_features_rejects_contract_drift() -> None:
93
+ bad = document()
94
+ bad["feature_names"] = list(FEATURE_NAMES[:-1])
95
+ with pytest.raises(ValueError, match="feature names"):
96
+ align_structure_features(
97
+ protein_ids=np.asarray(["p1", "p2", "p3"]),
98
+ safe_indices=np.arange(3),
99
+ mapping=mapping(),
100
+ feature_document=bad,
101
+ )
102
+
103
+
104
+ def test_read_mapping_rejects_duplicate_proteins(tmp_path: Path) -> None:
105
+ path = tmp_path / "mapping.jsonl"
106
+ path.write_text(
107
+ json.dumps({"protein_id": "p1"}) + "\n" + json.dumps({"protein_id": "p1"}) + "\n"
108
+ )
109
+ with pytest.raises(ValueError, match="duplicate"):
110
+ read_mapping(path)
recovery/tests/test_pocket_sources.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Pocket Atlas source mapping and canonical model selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from mitointeract_recovery.pocket_sources import (
7
+ build_protein_mapping,
8
+ place_sequence,
9
+ select_canonical_alphafold_model,
10
+ )
11
+
12
+
13
+ def test_place_sequence_exact_and_contiguous_constructs() -> None:
14
+ assert place_sequence("ACDE", "ACDE") == {
15
+ "placement": "exact",
16
+ "coverage": 1.0,
17
+ "sequence_start": 1,
18
+ "sequence_end": 4,
19
+ }
20
+ subsequence = place_sequence("CDE", "ACDEF")
21
+ assert subsequence == {
22
+ "placement": "subsequence",
23
+ "coverage": 0.6,
24
+ "sequence_start": 2,
25
+ "sequence_end": 4,
26
+ }
27
+ supersequence = place_sequence("XACDEY", "ACDE")
28
+ assert supersequence["placement"] == "supersequence"
29
+ assert supersequence["coverage"] == pytest.approx(4 / 6)
30
+ assert place_sequence("AAAA", "CCCC")["placement"] == "noncontiguous"
31
+
32
+
33
+ def test_select_canonical_model_excludes_isoforms_and_complexes() -> None:
34
+ models = [
35
+ {
36
+ "modelEntityId": "AF-P12345-2-F1",
37
+ "providerId": "GDM",
38
+ "entityType": "protein",
39
+ "isComplex": False,
40
+ "pdbUrl": "isoform",
41
+ "latestVersion": 6,
42
+ },
43
+ {
44
+ "modelEntityId": "AF-P12345-F1",
45
+ "providerId": "GDM",
46
+ "entityType": "protein",
47
+ "isComplex": False,
48
+ "pdbUrl": "canonical",
49
+ "latestVersion": 6,
50
+ },
51
+ {
52
+ "modelEntityId": "AF-P12345-F1",
53
+ "providerId": "NVIDIA",
54
+ "entityType": "protein",
55
+ "isComplex": True,
56
+ "pdbUrl": "complex",
57
+ "latestVersion": 1,
58
+ },
59
+ ]
60
+ selected = select_canonical_alphafold_model("P12345", models)
61
+ assert selected is not None
62
+ assert selected["pdbUrl"] == "canonical"
63
+ assert select_canonical_alphafold_model("Q99999", models) is None
64
+
65
+
66
+ def test_build_mapping_excludes_test_and_test_homology_clusters() -> None:
67
+ rows = [
68
+ {
69
+ "pair_id": "pair-1",
70
+ "protein_id": "p1",
71
+ "sequence": "ACDE",
72
+ "source_record_ids": ["s1"],
73
+ },
74
+ {
75
+ "pair_id": "pair-2",
76
+ "protein_id": "p2",
77
+ "sequence": "FGHI",
78
+ "source_record_ids": ["s2"],
79
+ },
80
+ {
81
+ "pair_id": "pair-3",
82
+ "protein_id": "p3",
83
+ "sequence": "KLMN",
84
+ "source_record_ids": ["s3"],
85
+ },
86
+ {
87
+ "pair_id": "pair-4",
88
+ "protein_id": "p4",
89
+ "sequence": "QRST",
90
+ "source_record_ids": ["s4"],
91
+ },
92
+ ]
93
+ mapping, audit = build_protein_mapping(
94
+ sample_rows=rows,
95
+ manifest={
96
+ "pair-1": "train",
97
+ "pair-2": "test",
98
+ "pair-3": "validation",
99
+ "pair-4": "validation",
100
+ },
101
+ source_accessions={"s1": "U1", "s2": "U2", "s3": "U3", "s4": "U4"},
102
+ clusters={"p1": "c1", "p2": "ct", "p3": "ct", "p4": "c4"},
103
+ uniprot_entries={
104
+ "U1": {"reviewed": "reviewed", "sequence": "ACDE", "sequence_version": 1},
105
+ "U4": {
106
+ "reviewed": "reviewed",
107
+ "sequence": "QRSTX",
108
+ "sequence_version": 2,
109
+ },
110
+ },
111
+ )
112
+ assert [row["protein_id"] for row in mapping] == ["p1", "p4"]
113
+ assert all(row["eligible"] for row in mapping)
114
+ assert mapping[1]["placement"] == "subsequence"
115
+ assert mapping[1]["coverage"] == 0.8
116
+ assert audit == {
117
+ "safe_rows": 2,
118
+ "safe_proteins": 2,
119
+ "test_clusters_reserved": 1,
120
+ "eligible_proteins": 2,
121
+ "eligible_accessions": 2,
122
+ }
123
+
124
+
125
+ def test_build_mapping_rejects_incomplete_manifest() -> None:
126
+ with pytest.raises(ValueError, match="pair set"):
127
+ build_protein_mapping(
128
+ sample_rows=[
129
+ {
130
+ "pair_id": "pair-1",
131
+ "protein_id": "p1",
132
+ "sequence": "ACDE",
133
+ "source_record_ids": [],
134
+ }
135
+ ],
136
+ manifest={},
137
+ source_accessions={},
138
+ clusters={"p1": "c1"},
139
+ uniprot_entries={},
140
+ )