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,256 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Boltz-2 validator.
4
+
5
+ Wraps `Boltz-2 <https://github.com/jwohlwend/boltz>`_ (MIT for both code and
6
+ weights). Boltz-2 takes a target+binder spec and produces:
7
+
8
+ - ``ipTM``, ``pAE_interaction`` from the structure prediction
9
+ - ``affinity_pred_value`` (continuous, log(IC50)-like: **lower = stronger**
10
+ predicted binder; the ranker inverts it). Ligand-only, so it stays ``None``
11
+ for the protein binders bindsight designs.
12
+ - ``affinity_probability_binary`` (probability the design is a binder vs decoy)
13
+
14
+ This module's responsibility is the *parsing + composition* layer:
15
+
16
+ - :func:`build_boltz_yaml` produces the YAML config Boltz-2 ingests.
17
+ - :func:`parse_boltz_output` reads Boltz's output JSON into a
18
+ :class:`bindsight.validate.protocol.ValidationResult`.
19
+ - :class:`Boltz2Validator` is the plugin entry point used by the orchestrator.
20
+
21
+ The actual GPU inference happens in the runner (Colab / Modal / local Docker)
22
+ because Boltz-2 needs CUDA. Calling :meth:`Boltz2Validator.validate` from the
23
+ orchestrator either:
24
+
25
+ 1. Reads pre-computed Boltz output from the run's ``validate/`` directory
26
+ (when the runner has already returned results), OR
27
+ 2. Raises :class:`MissingValidationError` with a clear message pointing the
28
+ user at the runner step.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import logging
35
+ import statistics
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ import yaml
40
+
41
+ from bindsight.validate.protocol import (
42
+ UNRECORDED_VERSION,
43
+ ValidationResult,
44
+ installed_version,
45
+ note_unmeasured,
46
+ )
47
+
48
+ LOG = logging.getLogger(__name__)
49
+
50
+ #: The Boltz-2 release this project pins, audited and installed.
51
+ #:
52
+ #: Single source of truth: ``bindsight.runners.tools.BOLTZ_PIP`` is built from
53
+ #: it. The dependency runs that way round because ``tools`` already imports this
54
+ #: module.
55
+ PINNED_BOLTZ2_VERSION = "2.0.3"
56
+
57
+
58
+ def installed_boltz_version() -> str | None:
59
+ """The Boltz-2 actually importable here, or ``None`` where there is none.
60
+
61
+ ``validator_version`` used to be :data:`PINNED_BOLTZ2_VERSION` written
62
+ straight into every metrics row, which made it a label rather than a record:
63
+ the pin was a range, so any 2.x could have produced a number, and the row
64
+ said ``2.0.1`` regardless. A provenance field that reports a constant is
65
+ worse than an absent one, because it answers the question wrongly instead of
66
+ leaving it open.
67
+
68
+ Returns ``None`` rather than the pin when Boltz is not installed — which is
69
+ the case wherever the parser runs outside the GPU environment. Substituting
70
+ the pinned version there would recreate the same fiction one level down.
71
+ """
72
+ return installed_version("boltz")
73
+
74
+
75
+ class MissingValidationError(FileNotFoundError):
76
+ """Raised when validation output for a binder hasn't been produced yet."""
77
+
78
+
79
+ def build_boltz_yaml(
80
+ *,
81
+ target_id: str,
82
+ target_sequence: str,
83
+ binder_id: str,
84
+ binder_sequence: str,
85
+ predict_affinity: bool = True,
86
+ ) -> dict[str, Any]:
87
+ """Build the YAML config Boltz-2 expects on stdin.
88
+
89
+ See https://github.com/jwohlwend/boltz/blob/main/docs/prediction.md for
90
+ the full schema. We stay on the documented happy path (two protein chains
91
+ + one affinity property) so the spec is portable across Boltz-2 minor
92
+ versions.
93
+ """
94
+ # Boltz-2 chain IDs must be short identifiers (single letters), not our
95
+ # binder_id tracking string ("binder_0_seq0" makes Boltz-2 skip the input).
96
+ # Use target_id for the target chain and a distinct single letter for the
97
+ # binder; binder_id is kept only for our own bookkeeping/filenames.
98
+ binder_chain = "B" if target_id != "B" else "C"
99
+ spec: dict[str, Any] = {
100
+ "version": 1,
101
+ "sequences": [
102
+ {"protein": {"id": target_id, "sequence": target_sequence}},
103
+ {"protein": {"id": binder_chain, "sequence": binder_sequence}},
104
+ ],
105
+ }
106
+ if predict_affinity:
107
+ spec["properties"] = [{"affinity": {"binder": binder_chain}}]
108
+ return spec
109
+
110
+
111
+ def write_boltz_yaml(spec: dict[str, Any], path: Path) -> Path:
112
+ """Write a Boltz-2 spec dict to disk as YAML."""
113
+ path.parent.mkdir(parents=True, exist_ok=True)
114
+ path.write_text(yaml.safe_dump(spec, sort_keys=False), encoding="utf-8", newline="\n")
115
+ return path
116
+
117
+
118
+ def parse_boltz_output(
119
+ *,
120
+ output_dir: Path,
121
+ binder_id: str,
122
+ target_uniprot: str,
123
+ ) -> ValidationResult:
124
+ """Parse Boltz-2's per-design output directory into a ValidationResult.
125
+
126
+ Boltz-2 writes one ``predictions/<run>/`` directory per spec, containing:
127
+
128
+ - ``confidence_<run>_model_0.json`` — per-residue + interface confidence
129
+ (we extract ``iptm`` and ``ptm``).
130
+ - ``affinity_<run>.json`` (when affinity prediction enabled) —
131
+ ``affinity_pred_value`` and ``affinity_probability_binary``.
132
+
133
+ Both files are read; missing fields become ``None`` (not an error — the
134
+ user may have disabled affinity prediction).
135
+ """
136
+ # Boltz writes to <out_dir>/predictions/<name>/confidence_*.json, so search
137
+ # recursively (rglob) rather than only the top level.
138
+ #
139
+ # *Every* one of them, not the first. With --diffusion_samples > 1 Boltz
140
+ # writes one per draw and ranks them by confidence descending, so
141
+ # ``model_0`` is the best draw rather than a representative one. Reading
142
+ # whichever file turned up first therefore reported a maximum over k — an
143
+ # estimate that improves as more samples are bought, with no change to the
144
+ # design. Averaging is the estimate of the distribution's centre; the best
145
+ # draw is not an estimate of anything.
146
+ confidence_paths = sorted(output_dir.rglob("confidence_*.json"))
147
+ affinity_path = next(output_dir.rglob("affinity_*.json"), None)
148
+
149
+ iptm = pae_interaction = affinity_value = affinity_prob = None
150
+ iptm_samples: list[float] = []
151
+ pae_samples: list[float] = []
152
+
153
+ n_parsed = 0
154
+ for path in confidence_paths:
155
+ try:
156
+ cdata = json.loads(path.read_text(encoding="utf-8"))
157
+ except (json.JSONDecodeError, OSError) as e:
158
+ LOG.warning("failed to parse %s: %s", path, e)
159
+ continue
160
+ n_parsed += 1
161
+ value = _safe_float(cdata.get("iptm"))
162
+ if value is not None:
163
+ iptm_samples.append(value)
164
+ pae = _safe_float(cdata.get("pae_interaction") or cdata.get("interface_pae"))
165
+ if pae is not None:
166
+ pae_samples.append(pae)
167
+
168
+ if iptm_samples:
169
+ iptm = statistics.fmean(iptm_samples)
170
+ if pae_samples:
171
+ pae_interaction = statistics.fmean(pae_samples)
172
+
173
+ if affinity_path is not None:
174
+ try:
175
+ adata = json.loads(affinity_path.read_text(encoding="utf-8"))
176
+ affinity_value = _safe_float(adata.get("affinity_pred_value"))
177
+ affinity_prob = _safe_float(adata.get("affinity_probability_binary"))
178
+ except (json.JSONDecodeError, OSError) as e:
179
+ LOG.warning("failed to parse %s: %s", affinity_path, e)
180
+
181
+ result = ValidationResult(
182
+ binder_id=binder_id,
183
+ target_uniprot=target_uniprot,
184
+ iptm=iptm,
185
+ iptm_n_samples=len(iptm_samples) or None,
186
+ iptm_sd=statistics.stdev(iptm_samples) if len(iptm_samples) > 1 else None,
187
+ pae_interaction=pae_interaction,
188
+ affinity_pred_value=affinity_value,
189
+ affinity_probability_binary=affinity_prob,
190
+ validator_name="boltz2",
191
+ validator_version=installed_boltz_version() or UNRECORDED_VERSION,
192
+ notes=(
193
+ # Files parsed, of files found. This counted the files present, so a
194
+ # directory of unreadable JSON reported the same sample count as a
195
+ # directory of good ones and the row carried no trace of it.
196
+ f"parsed confidence={n_parsed} of {len(confidence_paths)} file(s), "
197
+ f"affinity={'yes' if affinity_path else 'no'}"
198
+ ),
199
+ )
200
+ return note_unmeasured(
201
+ result,
202
+ reason=(
203
+ f"none of the {len(confidence_paths)} confidence file(s) under "
204
+ f"{output_dir} yielded an iptm or pae_interaction value"
205
+ ),
206
+ log=LOG,
207
+ )
208
+
209
+
210
+ def _safe_float(v: object) -> float | None:
211
+ if v is None:
212
+ return None
213
+ try:
214
+ return float(v) # type: ignore[arg-type]
215
+ except (TypeError, ValueError):
216
+ return None
217
+
218
+
219
+ class Boltz2Validator:
220
+ """Plugin: Boltz-2 structure + affinity prediction.
221
+
222
+ The validator class itself is a thin parser; the GPU inference happens
223
+ in whichever runner the user picks (Colab / Modal / local Docker). The
224
+ orchestrator (``bindsight validate``) is responsible for shipping the
225
+ spec to the runner and pulling back the JSON outputs into
226
+ ``<run>/validate/<binder_id>/``.
227
+ """
228
+
229
+ name = "boltz2"
230
+ version = PINNED_BOLTZ2_VERSION
231
+ license_notice = "Boltz-2: MIT (code + weights). Commercial-OK."
232
+
233
+ def validate(
234
+ self,
235
+ target_uniprot: str,
236
+ binder_id: str,
237
+ binder_sequence: str,
238
+ target_structure_path: str,
239
+ ) -> ValidationResult:
240
+ """Look up Boltz-2 output for this binder + parse it.
241
+
242
+ Looks in ``<cwd>/validate/<binder_id>/`` for the JSONs Boltz-2 wrote.
243
+ Raises :class:`MissingValidationError` if not found — the orchestrator
244
+ catches this and prints a hint about running the GPU step first.
245
+ """
246
+ cwd = Path("validate") / binder_id
247
+ if not cwd.exists():
248
+ raise MissingValidationError(
249
+ f"no Boltz-2 output found at {cwd}; "
250
+ "run the GPU validation step first (see docs/colab-design-howto.md)"
251
+ )
252
+ return parse_boltz_output(
253
+ output_dir=cwd,
254
+ binder_id=binder_id,
255
+ target_uniprot=target_uniprot,
256
+ )
@@ -0,0 +1,42 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Chai-1 validator plugin.
4
+
5
+ `Chai-1 <https://github.com/chaidiscovery/chai-lab>`_ (Apache-2) is an
6
+ alternative to Boltz-2 for cross-model agreement. The GPU inference runs in
7
+ :mod:`bindsight.runners.job_exec`; this plugin's :meth:`validate` parses the
8
+ Chai output the runner produced into a :class:`ValidationResult`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from bindsight.validate.boltz2 import MissingValidationError
16
+ from bindsight.validate.protocol import ValidationResult
17
+
18
+
19
+ class Chai1rValidator:
20
+ """Plugin: Chai-1 structure + confidence prediction (parser side)."""
21
+
22
+ name = "chai1r"
23
+ version = "0.6"
24
+ license_notice = "Chai-1: Apache-2.0. Commercial-OK."
25
+
26
+ def validate(
27
+ self,
28
+ target_uniprot: str,
29
+ binder_id: str,
30
+ binder_sequence: str,
31
+ target_structure_path: str,
32
+ ) -> ValidationResult:
33
+ """Parse Chai-1 output for this binder from ``validate/<binder_id>/``."""
34
+ from bindsight.runners.tools import parse_chai_output
35
+
36
+ cwd = Path("validate") / binder_id
37
+ if not cwd.exists():
38
+ raise MissingValidationError(
39
+ f"no Chai-1 output found at {cwd}; run the GPU validation step first "
40
+ "(bindsight validate --validator chai1r)"
41
+ )
42
+ return parse_chai_output(cwd, binder_id=binder_id, target_uniprot=target_uniprot)
@@ -0,0 +1,188 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Validator protocol.
4
+
5
+ Validators take a designed binder (sequence + maybe a structural model) plus
6
+ the target, and predict the complex structure + binding affinity. We treat
7
+ ``Boltz-2`` as the default; users can opt into ``Chai-1r`` for cross-model
8
+ agreement or ``AF2-IG`` for the gold-standard Bennet/Baker filtering pipeline
9
+ (PyRosetta is free only for non-commercial use — banner shown at CLI time).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import Protocol, runtime_checkable
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field
18
+
19
+ #: What a validator reports when its tool's version cannot be established.
20
+ #:
21
+ #: Deliberately not a version-shaped string. Every parser here used to write a
22
+ #: literal into :attr:`ValidationResult.validator_version` — ``"2.0.1"``,
23
+ #: ``"0.6"``, ``"1.0"`` — regardless of what was installed, which made the one
24
+ #: field that looks like provenance answer from a constant. A reader checking
25
+ #: which model produced a result got a confident wrong answer instead of an
26
+ #: obviously missing one, and no substitute that *looks* like a version fixes
27
+ #: that.
28
+ UNRECORDED_VERSION = "unrecorded"
29
+
30
+
31
+ def installed_version(distribution: str) -> str | None:
32
+ """The installed version of ``distribution``, or ``None`` if it is absent.
33
+
34
+ Validators parse output on whatever machine holds the files, which is not
35
+ always the machine that produced them; ``None`` is the honest answer there,
36
+ and callers turn it into :data:`UNRECORDED_VERSION`.
37
+
38
+ Args:
39
+ distribution: the installed distribution name, e.g. ``"boltz"``.
40
+
41
+ Returns:
42
+ The version string, or ``None`` when the distribution is not installed.
43
+ """
44
+ from importlib.metadata import PackageNotFoundError, version
45
+
46
+ try:
47
+ return version(distribution)
48
+ except PackageNotFoundError:
49
+ return None
50
+
51
+
52
+ class ValidationResult(BaseModel):
53
+ """Per-design validation metrics."""
54
+
55
+ model_config = ConfigDict(extra="forbid", frozen=True)
56
+
57
+ binder_id: str
58
+ target_uniprot: str
59
+ iptm: float | None = Field(None, ge=0.0, le=1.0)
60
+ ptm: float | None = Field(
61
+ None,
62
+ ge=0.0,
63
+ le=1.0,
64
+ description=(
65
+ "Predicted TM-score for the complex — a structure-confidence metric, NOT an "
66
+ "affinity. Kept in its own field so it can never be mistaken for one."
67
+ ),
68
+ )
69
+ plddt_binder: float | None = Field(
70
+ None,
71
+ ge=0.0,
72
+ le=100.0,
73
+ description=(
74
+ "Mean pLDDT over the binder chain (0-100 scale) — a structure-confidence "
75
+ "metric, NOT an affinity."
76
+ ),
77
+ )
78
+ pae_interaction: float | None = Field(None, ge=0.0)
79
+ rmsd_to_designed: float | None = Field(None, ge=0.0)
80
+ affinity_pred_value: float | None = Field(
81
+ None,
82
+ description=(
83
+ "Predicted binding affinity from a validator that actually predicts one. "
84
+ "LOWER IS STRONGER: Boltz-2 reports a log(IC50)-like value, so -8.0 is a "
85
+ "tighter binder than -6.0, and the ranker inverts it accordingly. Validators "
86
+ "that do not predict affinity MUST leave this None rather than substituting a "
87
+ "confidence metric — see ptm and plddt_binder."
88
+ ),
89
+ )
90
+ affinity_probability_binary: float | None = Field(
91
+ None,
92
+ ge=0.0,
93
+ le=1.0,
94
+ description="Probability the design is a binder vs. a decoy (early-discovery filter).",
95
+ )
96
+ iptm_n_samples: int | None = Field(
97
+ None,
98
+ ge=1,
99
+ description=(
100
+ "How many independent draws ``iptm`` averages. Boltz-2 builds "
101
+ "structures by diffusion, so one draw is a sample, not a "
102
+ "measurement — a reader cannot otherwise tell a single draw from an "
103
+ "average of five. ``None`` means the run predates this field and "
104
+ "the count is unknown."
105
+ ),
106
+ )
107
+ iptm_sd: float | None = Field(
108
+ None,
109
+ ge=0.0,
110
+ description=(
111
+ "Spread of ``iptm`` across those draws, or ``None`` when there was "
112
+ "only one. This is the metric's own noise, measured on the same "
113
+ "input in the same job, and it is what any difference between two "
114
+ "designs has to be larger than to mean anything."
115
+ ),
116
+ )
117
+ validator_name: str
118
+ validator_version: str
119
+ notes: str | None = None
120
+
121
+ @property
122
+ def measured(self) -> bool:
123
+ """True when the validator produced at least one metric.
124
+
125
+ A result whose every metric is ``None`` is still a valid row: it reaches
126
+ metrics.jsonl, the ranker and the report looking exactly like a design
127
+ that was scored and scored badly. It is not the same thing, and the
128
+ difference is invisible unless something says so.
129
+ """
130
+ return any(
131
+ value is not None
132
+ for value in (
133
+ self.iptm,
134
+ self.ptm,
135
+ self.plddt_binder,
136
+ self.pae_interaction,
137
+ self.affinity_pred_value,
138
+ )
139
+ )
140
+
141
+
142
+ @runtime_checkable
143
+ class Validator(Protocol):
144
+ """Protocol every validator plugin must implement."""
145
+
146
+ name: str
147
+ version: str
148
+ license_notice: str # Shown by the CLI before each run.
149
+
150
+ def validate(
151
+ self,
152
+ target_uniprot: str,
153
+ binder_id: str,
154
+ binder_sequence: str,
155
+ target_structure_path: str,
156
+ ) -> ValidationResult:
157
+ """Run the validator and return metrics."""
158
+ ...
159
+
160
+
161
+ #: The phrase a validator's ``notes`` carries when it parsed no metric at all.
162
+ #: Checked across every parser by the test suite, so the guarantee is uniform
163
+ #: rather than whichever parser someone remembered to handle.
164
+ NO_METRICS_NOTE = "no metrics parsed"
165
+
166
+
167
+ def note_unmeasured(
168
+ result: ValidationResult, *, reason: str, log: logging.Logger
169
+ ) -> ValidationResult:
170
+ """Warn and annotate when ``result`` carries no metric; pass it through otherwise.
171
+
172
+ The AF2 initial-guess parser returned an all-``None`` result for a score file
173
+ that was missing, truncated to its header, or written with different column
174
+ names -- three real failure modes, none of which produced a warning or left
175
+ any trace in the row. This is the single place that decides what such a
176
+ result says about itself.
177
+ """
178
+ if result.measured:
179
+ return result
180
+ log.warning(
181
+ "%s parsed no metrics for %s: %s",
182
+ result.validator_name,
183
+ result.binder_id,
184
+ reason,
185
+ )
186
+ existing = (result.notes or "").strip()
187
+ note = f"{NO_METRICS_NOTE}: {reason}"
188
+ return result.model_copy(update={"notes": f"{existing}; {note}" if existing else note})
@@ -0,0 +1,54 @@
1
+ # bindsight demo config — runs the discovery half on a REAL TCGA breast-cancer
2
+ # cohort (NIH/GDC). It discovers cell-surface, antibody-tractable antigens that
3
+ # are over-expressed in tumor vs. adjacent normal, ranked with full provenance.
4
+ # Well-known breast-cancer surface antigens (e.g. ERBB2/HER2) surface among the
5
+ # candidates when their signal is present in the sampled cohort; EGFR is often
6
+ # lower in bulk tumor than in normal breast epithelium, so it may rank as a
7
+ # down-regulated DEG rather than a top up-regulated target — that's the real
8
+ # biology, not a scripted result.
9
+ #
10
+ # On first run, `bindsight discover`/`demo` auto-downloads the cohort (STAR -
11
+ # Counts) from the GDC open-access API into the cache below, populates the full
12
+ # SURFY surfaceome list, and enriches the top up-regulated DEGs via Open
13
+ # Targets; subsequent runs are cached. Needs network the first time; takes a few
14
+ # minutes (real DESeq2 + enrichment on real data), not 30 seconds.
15
+
16
+ name: bindsight_demo_tcga_brca
17
+ out_dir: runs/demo
18
+
19
+ inputs:
20
+ # Auto-downloaded from GDC if absent (see bindsight/io/gdc.py). Cached here.
21
+ counts: data/gdc_cache/tcga_brca/counts.tsv.gz
22
+ design: data/gdc_cache/tcga_brca/design.tsv
23
+ download:
24
+ project: TCGA-BRCA
25
+ n_tumor: 20
26
+ n_normal: 20
27
+ gene_types: ["protein_coding"]
28
+
29
+ params:
30
+ deg:
31
+ design_formula: "~ condition"
32
+ contrast: ["condition", "tumor", "normal"]
33
+ fdr_threshold: 0.05
34
+ log2fc_threshold: 1.0
35
+ min_replicates: 3
36
+ min_count: 10
37
+
38
+ target_discovery:
39
+ require_surfy: true
40
+ surfy_allow_offline_fallback: true # attempt full SURFY; fall back if offline
41
+ use_open_targets: true # genuine discovery across the surfaceome
42
+ # The demo is expected to run offline, where Open Targets is unreachable
43
+ # and every safety-event count is unknown. A real run withholds those
44
+ # candidates; the demo accepts them so it still shows a full pipeline
45
+ # without a network. This is a deliberate loosening, stated here rather
46
+ # than hidden in a default.
47
+ open_targets_require_measured: false
48
+ require_tractable_modality: [] # don't pre-filter modality in the demo
49
+ max_safety_events: 100
50
+ require_surface_bind_site: false
51
+ top_n: 10
52
+
53
+ backend: colab
54
+ cheap_profile: false