bindsight 0.3.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. bindsight/README.md +44 -0
  2. bindsight/__init__.py +15 -0
  3. bindsight/benchmark/__init__.py +71 -0
  4. bindsight/benchmark/core.py +497 -0
  5. bindsight/benchmark/defaults.py +28 -0
  6. bindsight/benchmark/designer_bench.py +899 -0
  7. bindsight/benchmark/outcomes.py +508 -0
  8. bindsight/benchmark/panel.py +621 -0
  9. bindsight/benchmark/rediscovery.py +1153 -0
  10. bindsight/benchmark/statistics.py +622 -0
  11. bindsight/benchmark/study.py +1153 -0
  12. bindsight/benchmark/study_figures.py +164 -0
  13. bindsight/benchmark/study_report.py +607 -0
  14. bindsight/cli.py +2429 -0
  15. bindsight/config.py +451 -0
  16. bindsight/cost.py +258 -0
  17. bindsight/deg/__init__.py +7 -0
  18. bindsight/deg/pydeseq2_runner.py +285 -0
  19. bindsight/design/__init__.py +23 -0
  20. bindsight/design/_common.py +501 -0
  21. bindsight/design/bindcraft.py +78 -0
  22. bindsight/design/boltzgen.py +79 -0
  23. bindsight/design/developability.py +140 -0
  24. bindsight/design/embeddings.py +165 -0
  25. bindsight/design/prescreen.py +169 -0
  26. bindsight/design/protocol.py +111 -0
  27. bindsight/design/rfdiff_mpnn.py +93 -0
  28. bindsight/epitopes/__init__.py +7 -0
  29. bindsight/epitopes/surface_bind.py +134 -0
  30. bindsight/export/__init__.py +7 -0
  31. bindsight/export/ro_crate.py +595 -0
  32. bindsight/io/__init__.py +19 -0
  33. bindsight/io/cbioportal.py +118 -0
  34. bindsight/io/gdc.py +343 -0
  35. bindsight/io/paths.py +124 -0
  36. bindsight/pipelines/__init__.py +9 -0
  37. bindsight/pipelines/caveats.py +60 -0
  38. bindsight/pipelines/discover.py +1443 -0
  39. bindsight/pipelines/full_run.py +410 -0
  40. bindsight/plugins.py +335 -0
  41. bindsight/provenance/__init__.py +34 -0
  42. bindsight/provenance/append.py +199 -0
  43. bindsight/provenance/fragments.py +216 -0
  44. bindsight/provenance/manifest.py +506 -0
  45. bindsight/rank/__init__.py +7 -0
  46. bindsight/rank/scoring.py +355 -0
  47. bindsight/report/__init__.py +39 -0
  48. bindsight/report/format.py +100 -0
  49. bindsight/report/html.py +645 -0
  50. bindsight/report/showcase.py +653 -0
  51. bindsight/report/templates/report.css +67 -0
  52. bindsight/report/templates/report.html.j2 +420 -0
  53. bindsight/report/theme.py +231 -0
  54. bindsight/report/web/__init__.py +35 -0
  55. bindsight/report/web/app.py +624 -0
  56. bindsight/report/web/static/binder_viewer.js +245 -0
  57. bindsight/report/web/static/bindsight.css +455 -0
  58. bindsight/report/web/static/charts.js +425 -0
  59. bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
  60. bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
  61. bindsight/report/web/static/your_data_check.js +370 -0
  62. bindsight/report/web/templates/base.html.j2 +48 -0
  63. bindsight/report/web/templates/evidence.html.j2 +341 -0
  64. bindsight/report/web/templates/overview.html.j2 +140 -0
  65. bindsight/report/web/templates/runs.html.j2 +125 -0
  66. bindsight/report/web/templates/try.html.j2 +155 -0
  67. bindsight/report/web/templates/your_data.html.j2 +46 -0
  68. bindsight/runners/__init__.py +7 -0
  69. bindsight/runners/colab.py +158 -0
  70. bindsight/runners/job_exec.py +1110 -0
  71. bindsight/runners/kaggle.py +425 -0
  72. bindsight/runners/kaggle_kernel.py +489 -0
  73. bindsight/runners/local_docker.py +154 -0
  74. bindsight/runners/mock.py +167 -0
  75. bindsight/runners/modal_runner.py +199 -0
  76. bindsight/runners/notebook.py +116 -0
  77. bindsight/runners/notebook_content.py +217 -0
  78. bindsight/runners/protocol.py +81 -0
  79. bindsight/runners/source_wheel.py +104 -0
  80. bindsight/runners/tools.py +997 -0
  81. bindsight/structures/__init__.py +7 -0
  82. bindsight/structures/alphafolddb.py +93 -0
  83. bindsight/structures/plddt.py +92 -0
  84. bindsight/structures/topology.py +227 -0
  85. bindsight/surfaceome/__init__.py +42 -0
  86. bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
  87. bindsight/surfaceome/data/surfy_offline.txt +17 -0
  88. bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
  89. bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
  90. bindsight/surfaceome/surfy.py +441 -0
  91. bindsight/targets/__init__.py +7 -0
  92. bindsight/targets/ensembl_uniprot.py +56 -0
  93. bindsight/targets/gtex.py +250 -0
  94. bindsight/targets/open_targets.py +239 -0
  95. bindsight/validate/__init__.py +7 -0
  96. bindsight/validate/af2_ig.py +49 -0
  97. bindsight/validate/boltz2.py +256 -0
  98. bindsight/validate/chai1r.py +42 -0
  99. bindsight/validate/protocol.py +188 -0
  100. bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
  101. bindsight-0.3.5.dist-info/METADATA +495 -0
  102. bindsight-0.3.5.dist-info/RECORD +106 -0
  103. bindsight-0.3.5.dist-info/WHEEL +4 -0
  104. bindsight-0.3.5.dist-info/entry_points.txt +20 -0
  105. bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
  106. bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
@@ -0,0 +1,250 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Normal-tissue expression from GTEx (on-target / off-tumor safety).
4
+
5
+ A good antibody / ADC target is over-expressed in tumour but **low in vital normal
6
+ tissues** — otherwise the binder attacks healthy heart, brain, liver or lung
7
+ (on-target/off-tumor toxicity). bindsight ranks candidates from tumour RNA-seq;
8
+ this module adds the missing other half: median expression across normal tissues,
9
+ straight from GTEx v8 (the reference normal-tissue RNA atlas), so a candidate's
10
+ vital-tissue expression can gate it.
11
+
12
+ Data (real, cached): GTEx v8 gene-level median TPM per tissue (GCT), ~7 MB:
13
+ https://storage.googleapis.com/adult-gtex/bulk-gex/v8/rna-seq/
14
+ GTEx_Analysis_2017-06-05_v8_RNASeQCv1.1.9_gene_median_tpm.gct.gz
15
+
16
+ Fails closed. The gate exists to keep unsafe targets out, so "we could not check"
17
+ must never read as "checked and safe": an unavailable reference raises
18
+ :class:`GTExUnavailableError`, and a gene the reference does not cover is reported
19
+ as ``unassessed`` (:meth:`GTExTissueExpression.assess`) rather than silently
20
+ passing the gate.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import contextlib
26
+ import logging
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Literal
30
+
31
+ import pandas as pd
32
+ import requests
33
+ from tenacity import (
34
+ retry,
35
+ retry_if_exception_type,
36
+ stop_after_attempt,
37
+ wait_exponential,
38
+ )
39
+
40
+ from bindsight.io.paths import cache_dir
41
+
42
+ LOG = logging.getLogger(__name__)
43
+
44
+ GTEX_MEDIAN_TPM_URL = (
45
+ "https://storage.googleapis.com/adult-gtex/bulk-gex/v8/rna-seq/"
46
+ "GTEx_Analysis_2017-06-05_v8_RNASeQCv1.1.9_gene_median_tpm.gct.gz"
47
+ )
48
+
49
+
50
+ class GTExUnavailableError(RuntimeError):
51
+ """The GTEx reference could not be obtained or parsed.
52
+
53
+ Raised instead of returning "no expression found", which a caller would read
54
+ as a clean safety verdict on a target whose normal-tissue expression was in
55
+ fact never checked.
56
+ """
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class TissueSafety:
61
+ """One gene's normal-tissue verdict against a vital-tissue TPM ceiling."""
62
+
63
+ status: Literal["safe", "unsafe", "unassessed"]
64
+ max_tpm: float | None
65
+ reason: str
66
+ #: The vital tissues this verdict actually looked at, and those it could not.
67
+ #:
68
+ #: A requested tissue with no matching GTEx column was silently dropped and
69
+ #: the maximum taken over whatever remained, so a gene highly expressed in a
70
+ #: dropped tissue was reported "safe" by a check that never looked at it.
71
+ #: A safety verdict has to say what it examined.
72
+ tissues_checked: tuple[str, ...] = ()
73
+ tissues_unrecognised: tuple[str, ...] = ()
74
+
75
+
76
+ def normalize_tissue(name: str) -> str:
77
+ """Normalise a GTEx tissue label to a config-friendly key.
78
+
79
+ 'Heart - Left Ventricle' -> 'heart_left_ventricle'; 'Brain - Cortex' ->
80
+ 'brain_cortex'; 'Liver' -> 'liver'.
81
+ """
82
+ return name.strip().lower().replace(" - ", "_").replace(" ", "_").replace("-", "_")
83
+
84
+
85
+ class GTExTissueExpression:
86
+ """Cached GTEx median-TPM-by-tissue lookup, keyed by Ensembl gene id."""
87
+
88
+ def __init__(
89
+ self,
90
+ cache_subdir: str = "gtex",
91
+ url: str = GTEX_MEDIAN_TPM_URL,
92
+ gct_path: Path | str | None = None,
93
+ timeout: float = 180.0,
94
+ session: requests.Session | None = None,
95
+ ) -> None:
96
+ self.cache = cache_dir(cache_subdir)
97
+ self.url = url
98
+ # When provided (tests / vendored data) the GCT is read directly, no network.
99
+ self.gct_path = Path(gct_path) if gct_path else None
100
+ self.timeout = timeout
101
+ self.session = session or requests.Session()
102
+ self.session.headers.setdefault(
103
+ "User-Agent", "bindsight/0.0.1 (+https://github.com/mikhaeelatefrizk/bindsight)"
104
+ )
105
+ self._df: pd.DataFrame | None = None
106
+
107
+ def _parquet_cache_path(self) -> Path:
108
+ return self.cache / "gene_median_tpm.parquet"
109
+
110
+ @retry(
111
+ retry=retry_if_exception_type(requests.RequestException),
112
+ wait=wait_exponential(multiplier=1, min=2, max=30),
113
+ stop=stop_after_attempt(5),
114
+ reraise=True,
115
+ )
116
+ def _download(self, url: str) -> bytes:
117
+ resp = self.session.get(url, timeout=self.timeout)
118
+ resp.raise_for_status()
119
+ return resp.content
120
+
121
+ def _ensure_gct(self) -> Path:
122
+ cached = self.cache / "gene_median_tpm.gct.gz"
123
+ if cached.exists():
124
+ return cached
125
+ try:
126
+ payload = self._download(self.url)
127
+ except requests.RequestException as e:
128
+ raise GTExUnavailableError(f"GTEx download failed: {e}") from e
129
+ cached.write_bytes(payload)
130
+ return cached
131
+
132
+ def _load(self) -> pd.DataFrame:
133
+ """Return a DataFrame indexed by unversioned Ensembl id, columns = tissues.
134
+
135
+ Raises:
136
+ GTExUnavailableError: The reference is missing or unparseable, so no
137
+ normal-tissue claim can be made about any gene.
138
+ """
139
+ if self._df is not None:
140
+ return self._df
141
+ # Fast path: parsed parquet cache (only for the live/downloaded source).
142
+ parq = self._parquet_cache_path()
143
+ if self.gct_path is None and parq.exists():
144
+ try:
145
+ self._df = pd.read_parquet(parq)
146
+ return self._df
147
+ except Exception as e: # corrupt cache — rebuild
148
+ LOG.warning("GTEx parquet cache unreadable (%s); rebuilding", e)
149
+
150
+ gct = self.gct_path or self._ensure_gct()
151
+ if not Path(gct).exists():
152
+ raise GTExUnavailableError(f"GTEx reference not found at {gct}")
153
+ try:
154
+ df = pd.read_csv(gct, sep="\t", skiprows=2)
155
+ df["__gene"] = df["Name"].str.split(".").str[0]
156
+ df = df.drop(columns=["Name", "Description"]).set_index("__gene")
157
+ df.columns = [normalize_tissue(c) for c in df.columns]
158
+ df = df[~df.index.duplicated(keep="first")]
159
+ except Exception as e:
160
+ raise GTExUnavailableError(f"GTEx GCT parse failed for {gct}: {e}") from e
161
+ self._df = df
162
+ if self.gct_path is None:
163
+ # parquet cache is an optimisation, not required
164
+ with contextlib.suppress(Exception):
165
+ df.to_parquet(parq)
166
+ return df
167
+
168
+ def max_expression(self, ensembl_id: str, tissues: list[str]) -> float | None:
169
+ """Max median TPM of ``ensembl_id`` across ``tissues``.
170
+
171
+ ``None`` means *not measured here* — no gene id, the gene isn't in GTEx,
172
+ or none of the requested tissues exist. It never means "low expression";
173
+ use :meth:`assess` to get that distinction as a verdict.
174
+
175
+ Raises:
176
+ GTExUnavailableError: The reference itself is unavailable.
177
+ """
178
+ if not ensembl_id:
179
+ return None
180
+ df = self._load()
181
+ gene = ensembl_id.split(".")[0]
182
+ if gene not in df.index:
183
+ return None
184
+ cols = [c for c in (normalize_tissue(t) for t in tissues) if c in df.columns]
185
+ if not cols:
186
+ return None
187
+ return float(df.loc[gene, cols].max())
188
+
189
+ def coverage(self, tissues: list[str]) -> tuple[tuple[str, ...], tuple[str, ...]]:
190
+ """Split ``tissues`` into those GTEx can answer for and those it cannot.
191
+
192
+ Returns ``(checked, unrecognised)``, both in config spelling. The
193
+ unrecognised half used to be dropped without a word, which turned a
194
+ partial check into a clean bill of health.
195
+ """
196
+ try:
197
+ columns = set(self._load().columns)
198
+ except Exception: # a reference that will not load answers for nothing
199
+ return (), tuple(tissues)
200
+ checked = tuple(t for t in tissues if normalize_tissue(t) in columns)
201
+ return checked, tuple(t for t in tissues if normalize_tissue(t) not in columns)
202
+
203
+ def assess(self, ensembl_id: str, tissues: list[str], max_tpm: float) -> TissueSafety:
204
+ """Verdict for one gene against the vital-tissue ceiling ``max_tpm``.
205
+
206
+ Args:
207
+ ensembl_id: Ensembl gene id (a version suffix is stripped).
208
+ tissues: Vital tissues to check, in config spelling.
209
+ max_tpm: Median-TPM ceiling above which the target is unsafe.
210
+
211
+ Returns:
212
+ A :class:`TissueSafety` whose ``status`` separates a measured pass
213
+ (``safe``) from an absent measurement (``unassessed``). Only ``safe``
214
+ may be presented as having cleared the normal-tissue gate.
215
+
216
+ Raises:
217
+ GTExUnavailableError: The reference is unavailable, so the gate cannot
218
+ be applied to any candidate at all.
219
+ """
220
+ value = self.max_expression(ensembl_id, tissues)
221
+ checked, unrecognised = self.coverage(tissues)
222
+ # Named in every verdict, not only the failing ones: a reader checking
223
+ # whether brain was examined should not have to infer it from silence.
224
+ gap = f" Not examined (no GTEx column): {', '.join(unrecognised)}." if unrecognised else ""
225
+ if value is None:
226
+ return TissueSafety(
227
+ status="unassessed",
228
+ max_tpm=None,
229
+ reason=f"{ensembl_id or '<no gene id>'} has no GTEx median-TPM entry for "
230
+ f"{', '.join(tissues) or '<no tissues>'}" + gap,
231
+ tissues_checked=checked,
232
+ tissues_unrecognised=unrecognised,
233
+ )
234
+ if value > max_tpm:
235
+ return TissueSafety(
236
+ status="unsafe",
237
+ max_tpm=value,
238
+ reason=f"median TPM {value:.1f} exceeds the {max_tpm:.1f} vital-tissue ceiling"
239
+ + gap,
240
+ tissues_checked=checked,
241
+ tissues_unrecognised=unrecognised,
242
+ )
243
+ return TissueSafety(
244
+ status="safe",
245
+ max_tpm=value,
246
+ reason=f"median TPM {value:.1f} is at or below the {max_tpm:.1f} "
247
+ f"vital-tissue ceiling" + gap,
248
+ tissues_checked=checked,
249
+ tissues_unrecognised=unrecognised,
250
+ )
@@ -0,0 +1,239 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Open Targets Platform GraphQL client.
4
+
5
+ Open Targets aggregates target-disease association evidence from ~20 sources
6
+ (literature, genetic associations, somatic mutations, drug evidence, etc.).
7
+ We use it to enrich differentially-expressed candidates with druggability,
8
+ known disease links, safety liabilities, and tractability scores.
9
+
10
+ API docs: https://platform-docs.opentargets.org/data-access/graphql-api
11
+ Endpoint: https://api.platform.opentargets.org/api/v4/graphql
12
+
13
+ Rate limits are generous but not unlimited. We back off with tenacity.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ import time as _time
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import requests
26
+ from pydantic import BaseModel, ConfigDict, Field
27
+ from tenacity import (
28
+ retry,
29
+ retry_if_exception_type,
30
+ stop_after_attempt,
31
+ wait_exponential,
32
+ )
33
+
34
+ from bindsight.io.paths import cache_dir
35
+
36
+ LOG = logging.getLogger(__name__)
37
+
38
+ OPEN_TARGETS_URL = "https://api.platform.opentargets.org/api/v4/graphql"
39
+
40
+ # A focused query: per Ensembl gene ID, fetch the bits we actually use for
41
+ # target prioritization downstream. Kept small to stay friendly to the API.
42
+ _TARGET_QUERY = """
43
+ query Target($ensemblId: String!) {
44
+ target(ensemblId: $ensemblId) {
45
+ id
46
+ approvedSymbol
47
+ approvedName
48
+ biotype
49
+ proteinIds {
50
+ id
51
+ source
52
+ }
53
+ tractability {
54
+ modality
55
+ label
56
+ value
57
+ }
58
+ safetyLiabilities {
59
+ event
60
+ datasource
61
+ }
62
+ associatedDiseases(page: { index: 0, size: 20 }) {
63
+ count
64
+ rows {
65
+ score
66
+ disease {
67
+ id
68
+ name
69
+ therapeuticAreas {
70
+ id
71
+ name
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ """
79
+
80
+
81
+ class TargetEvidence(BaseModel):
82
+ """Slim record of Open Targets evidence for a single gene."""
83
+
84
+ model_config = ConfigDict(extra="ignore")
85
+
86
+ ensembl_id: str
87
+ symbol: str | None = None
88
+ name: str | None = None
89
+ biotype: str | None = None
90
+ uniprot_ids: list[str] = Field(default_factory=list)
91
+ tractability_modalities: list[str] = Field(
92
+ default_factory=list,
93
+ description="e.g. ['Antibody', 'SmallMolecule', 'PROTAC']. Sourced from "
94
+ "tractability.value=true rows.",
95
+ )
96
+ safety_event_count: int = 0
97
+ top_disease_associations: list[dict[str, Any]] = Field(default_factory=list)
98
+
99
+
100
+ #: How long an Open Targets response may be reused. Their releases are roughly
101
+ #: quarterly and move association scores, which move the ranking; an entry older
102
+ #: than this describes a different release from the one a run claims to have
103
+ #: used. Refetching costs one request against a cache that is otherwise warm.
104
+ CACHE_MAX_AGE_DAYS = 30
105
+
106
+
107
+ class OpenTargetsClient:
108
+ """Cached, retrying GraphQL client for Open Targets."""
109
+
110
+ def __init__(
111
+ self,
112
+ endpoint: str = OPEN_TARGETS_URL,
113
+ cache_subdir: str = "opentargets",
114
+ timeout: float = 30.0,
115
+ session: requests.Session | None = None,
116
+ ) -> None:
117
+ self.endpoint = endpoint
118
+ self.cache = cache_dir(cache_subdir)
119
+ self.timeout = timeout
120
+ self.session = session or requests.Session()
121
+ self.session.headers.setdefault(
122
+ "User-Agent", "bindsight/0.0.1 (+https://github.com/mikhaeelatefrizk/bindsight)"
123
+ )
124
+
125
+ def _cache_key(self, query: str, variables: dict[str, Any]) -> Path:
126
+ """Where a response is cached.
127
+
128
+ The endpoint is part of the key, and the file records when it was
129
+ fetched. Neither was true before: the key was a hash of the query text
130
+ and variables alone, and a hit was returned unconditionally however old
131
+ it was and whichever endpoint produced it. So a warm cache from an
132
+ earlier Open Targets release supplied that release's association scores
133
+ to today's run while the provenance recorded today's date -- two
134
+ machines, the same command, different candidate rankings, and nothing in
135
+ either manifest saying which.
136
+
137
+ The repository already accepted exactly this reasoning for the
138
+ surfaceome (``tests/test_silent_success.py`` -- "two runs on two machines
139
+ could use different surfaceome lists ... and nothing in either manifest
140
+ would say which"). Open Targets is a larger input to the ranking than
141
+ the surfaceome is.
142
+ """
143
+ payload = json.dumps(
144
+ {"endpoint": self.endpoint, "query": query, "variables": variables},
145
+ sort_keys=True,
146
+ )
147
+ sig = hashlib.sha256(payload.encode()).hexdigest()
148
+ return self.cache / f"{sig}.json"
149
+
150
+ @retry(
151
+ retry=retry_if_exception_type(requests.RequestException),
152
+ wait=wait_exponential(multiplier=1, min=2, max=30),
153
+ stop=stop_after_attempt(5),
154
+ reraise=True,
155
+ )
156
+ def _post(self, query: str, variables: dict[str, Any]) -> dict[str, Any]:
157
+ resp = self.session.post(
158
+ self.endpoint,
159
+ json={"query": query, "variables": variables},
160
+ timeout=self.timeout,
161
+ )
162
+ resp.raise_for_status()
163
+ body = resp.json()
164
+ if "errors" in body:
165
+ raise RuntimeError(f"Open Targets GraphQL errors: {body['errors']}")
166
+ data: dict[str, Any] = body["data"]
167
+ return data
168
+
169
+ def query(self, query: str, variables: dict[str, Any]) -> dict[str, Any]:
170
+ """Execute a raw GraphQL query, with on-disk caching.
171
+
172
+ A cached entry older than :data:`CACHE_MAX_AGE_DAYS` is refetched. The
173
+ cache used to have no expiry at all, so a response fetched months ago
174
+ was served to a run whose provenance stamped today -- the run was
175
+ reproducible against that machine's disk and against nothing else.
176
+ """
177
+ key = self._cache_key(query, variables)
178
+ if key.exists():
179
+ try:
180
+ envelope = json.loads(key.read_text(encoding="utf-8"))
181
+ except (OSError, json.JSONDecodeError):
182
+ envelope = None
183
+ if isinstance(envelope, dict) and "fetched_at" in envelope:
184
+ age = _time.time() - float(envelope.get("fetched_at") or 0)
185
+ if age <= CACHE_MAX_AGE_DAYS * 86400:
186
+ cached: dict[str, Any] = envelope.get("data") or {}
187
+ return cached
188
+ LOG.info(
189
+ "Open Targets cache entry is %.0f days old; refetching",
190
+ age / 86400,
191
+ )
192
+ else:
193
+ # An entry from before the cache recorded its age. Its release
194
+ # is unknown, so it cannot be trusted to describe today's run.
195
+ LOG.info("Open Targets cache entry predates age recording; refetching")
196
+
197
+ data = self._post(query, variables)
198
+ key.write_text(
199
+ json.dumps({"fetched_at": _time.time(), "endpoint": self.endpoint, "data": data}),
200
+ encoding="utf-8",
201
+ newline="\n",
202
+ )
203
+ return data
204
+
205
+ def get_target(self, ensembl_id: str) -> TargetEvidence | None:
206
+ """Fetch the target evidence record for an Ensembl gene ID.
207
+
208
+ Returns ``None`` if Open Targets has no record for the gene.
209
+ """
210
+ data = self.query(_TARGET_QUERY, {"ensemblId": ensembl_id})
211
+ t = data.get("target")
212
+ if t is None:
213
+ return None
214
+
215
+ uniprot_ids = [
216
+ p["id"] for p in (t.get("proteinIds") or []) if p.get("source") == "uniprot_swissprot"
217
+ ]
218
+ modalities = sorted(
219
+ {row["modality"] for row in (t.get("tractability") or []) if row.get("value") is True}
220
+ )
221
+ diseases = [
222
+ {
223
+ "score": row["score"],
224
+ "disease_id": row["disease"]["id"],
225
+ "disease_name": row["disease"]["name"],
226
+ "therapeutic_areas": [ta["name"] for ta in row["disease"]["therapeuticAreas"]],
227
+ }
228
+ for row in (t.get("associatedDiseases", {}) or {}).get("rows", [])
229
+ ]
230
+ return TargetEvidence(
231
+ ensembl_id=t["id"],
232
+ symbol=t.get("approvedSymbol"),
233
+ name=t.get("approvedName"),
234
+ biotype=t.get("biotype"),
235
+ uniprot_ids=uniprot_ids,
236
+ tractability_modalities=modalities,
237
+ safety_event_count=len(t.get("safetyLiabilities") or []),
238
+ top_disease_associations=diseases,
239
+ )
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Validator plugin interface (Boltz-2, Chai-1r, AF2-IG)."""
4
+
5
+ from bindsight.validate.protocol import ValidationResult, Validator
6
+
7
+ __all__ = ["ValidationResult", "Validator"]
@@ -0,0 +1,49 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """AF2-IG validator plugin (opt-in; PyRosetta is free only for non-commercial use).
4
+
5
+ AF2 with initial guess (`Bennett/Baker dl_binder_design
6
+ <https://github.com/nrbennet/dl_binder_design>`_) is the gold-standard binder
7
+ filter — but depends on PyRosetta, which is free only for non-commercial use; the
8
+ AlphaFold2 parameters themselves are CC BY 4.0. The CLI prints
9
+ a license banner before invoking this plugin. The GPU inference runs in
10
+ :mod:`bindsight.runners.job_exec`; this plugin's :meth:`validate` parses the
11
+ AF2 initial-guess score file the runner produced.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from bindsight.validate.boltz2 import MissingValidationError
19
+ from bindsight.validate.protocol import ValidationResult
20
+
21
+
22
+ class AF2IGValidator:
23
+ """Plugin: AlphaFold2 with initial guess (PyRosetta: non-commercial use only)."""
24
+
25
+ name = "af2_ig"
26
+ version = "1.0"
27
+ license_notice = (
28
+ "AF2-IG depends on PyRosetta, which is free only for non-commercial use; "
29
+ "the AlphaFold2 parameters are CC BY 4.0. See LICENSING.md § 3."
30
+ )
31
+
32
+ def validate(
33
+ self,
34
+ target_uniprot: str,
35
+ binder_id: str,
36
+ binder_sequence: str,
37
+ target_structure_path: str,
38
+ ) -> ValidationResult:
39
+ """Parse AF2 initial-guess output for this binder from ``validate/<binder_id>/``."""
40
+ from bindsight.runners.tools import parse_af2ig_output
41
+
42
+ cwd = Path("validate") / binder_id
43
+ score_file = cwd / "af2_scores.sc"
44
+ if not score_file.exists():
45
+ raise MissingValidationError(
46
+ f"no AF2-IG output found at {score_file}; run the GPU validation step first "
47
+ "(bindsight validate --validator af2_ig)"
48
+ )
49
+ return parse_af2ig_output(score_file, binder_id=binder_id, target_uniprot=target_uniprot)