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,653 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Read-only loaders for the real, committed benchmark results.
4
+
5
+ ``benchmarks/`` already holds the strongest evidence this project has:
6
+
7
+ - ``benchmarks/study/`` — the rediscovery study over fifteen whole, unstratified
8
+ TCGA projects (CA9 surfaced at rank 1 of 291), with its rank and outcome-class
9
+ figures.
10
+ - ``benchmarks/designer_benchmark/`` — 20 real ERBB2 binders designed on a free
11
+ Kaggle T4, each with the actual Boltz-2 predicted complex ``.cif``, per-design
12
+ metrics, developability descriptors, and ESM-2 embedding coords. The run used
13
+ the corrected ProteinMPNN protocol, and every design carries a target chain
14
+ byte-identical to the native domain IV.
15
+
16
+ Until now none of it was reachable from the web app, so a visitor saw a Demo
17
+ button and had to take the science on faith. This module is the single source
18
+ of truth both the web interface and the documentation site read from, so the
19
+ numbers on screen can never drift from the numbers in ``benchmarks/``.
20
+
21
+ Everything here is read-only, network-free, and degrades to ``None`` rather
22
+ than raising: ``benchmarks/`` is not packaged into the wheel
23
+ (``pyproject.toml`` ships only the ``bindsight`` package), so a user who
24
+ installed from a wheel has no such tree and sees the page degrade.
25
+
26
+ There was a second deployment once, built from an image that copied a named
27
+ set of paths and did not include ``benchmarks/`` -- so ``benchmarks_root()``
28
+ returned ``None`` there and the Real results page rendered nothing while the
29
+ landing page promised twenty binders in 3-D. A named subset is how that
30
+ happens. The container this project publishes copies the working tree whole,
31
+ and the deployment that did not is retired.
32
+
33
+ Only the standard library is imported at module scope, so this stays importable
34
+ without pandas -- which is what lets the CLI read a committed result on a machine
35
+ that never installed the discovery extra.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import csv
41
+ import json
42
+ import os
43
+ from dataclasses import dataclass, field
44
+ from pathlib import Path
45
+ from typing import Any
46
+
47
+ ENV_BENCHMARKS_DIR = "BINDSIGHT_BENCHMARKS_DIR"
48
+
49
+ _STUDY_SUBDIR = "study"
50
+ _DESIGNER_SUBDIR = "designer_benchmark"
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Location
55
+ # ---------------------------------------------------------------------------
56
+ def benchmarks_root() -> Path | None:
57
+ """Locate the repository's ``benchmarks/`` tree.
58
+
59
+ Honours the ``BINDSIGHT_BENCHMARKS_DIR`` environment variable first — the
60
+ same override style as ``BINDSIGHT_SURFACE_BIND_DATA`` — then walks up from
61
+ this file looking for the directory.
62
+
63
+ Returns:
64
+ The ``benchmarks/`` directory, or ``None`` when running from a wheel
65
+ install where it was never shipped.
66
+ """
67
+ override = os.environ.get(ENV_BENCHMARKS_DIR)
68
+ if override:
69
+ p = Path(override).expanduser()
70
+ return p if p.is_dir() else None
71
+
72
+ here = Path(__file__).resolve()
73
+ for parent in here.parents:
74
+ candidate = parent / "benchmarks"
75
+ if (candidate / _STUDY_SUBDIR).is_dir() or (candidate / _DESIGNER_SUBDIR).is_dir():
76
+ return candidate
77
+ return None
78
+
79
+
80
+ def _read_json(path: Path) -> dict[str, Any] | None:
81
+ """Parse ``path`` as JSON, returning ``None`` if absent or malformed."""
82
+ try:
83
+ with path.open(encoding="utf-8") as fh:
84
+ data = json.load(fh)
85
+ except (OSError, json.JSONDecodeError):
86
+ return None
87
+ return data if isinstance(data, dict) else None
88
+
89
+
90
+ def _read_tsv(path: Path) -> list[dict[str, str]]:
91
+ """Parse a TSV into a list of row dicts, returning ``[]`` on any failure."""
92
+ try:
93
+ with path.open(encoding="utf-8", newline="") as fh:
94
+ return list(csv.DictReader(fh, delimiter="\t"))
95
+ except OSError:
96
+ return []
97
+
98
+
99
+ def _read_jsonl(path: Path) -> list[dict[str, Any]]:
100
+ """Parse a JSON-lines file, skipping unparseable lines."""
101
+ rows: list[dict[str, Any]] = []
102
+ try:
103
+ with path.open(encoding="utf-8") as fh:
104
+ for line in fh:
105
+ line = line.strip()
106
+ if not line:
107
+ continue
108
+ try:
109
+ obj = json.loads(line)
110
+ except json.JSONDecodeError:
111
+ continue
112
+ if isinstance(obj, dict):
113
+ rows.append(obj)
114
+ except OSError:
115
+ return []
116
+ return rows
117
+
118
+
119
+ def _as_float(value: object) -> float | None:
120
+ """Coerce to float, returning ``None`` for missing or non-numeric input."""
121
+ if value is None or value == "":
122
+ return None
123
+ try:
124
+ return float(value) # type: ignore[arg-type]
125
+ except (TypeError, ValueError):
126
+ return None
127
+
128
+
129
+ def _as_optional_bool(value: object) -> bool | None:
130
+ """Coerce a JSON boolean, returning ``None`` when it is absent or unreadable.
131
+
132
+ Provenance that cannot be read must never collapse into ``False``: an
133
+ artifact with no flag has *unknown* provenance, and a caller has to be able
134
+ to tell that apart from an affirmative "this was recorded as real".
135
+
136
+ Args:
137
+ value: The raw value read from the artifact, or ``None`` when absent.
138
+
139
+ Returns:
140
+ The boolean, or ``None`` when the artifact did not state one.
141
+ """
142
+ if isinstance(value, bool):
143
+ return value
144
+ if isinstance(value, str):
145
+ lowered = value.strip().lower()
146
+ if lowered in {"true", "yes", "1"}:
147
+ return True
148
+ if lowered in {"false", "no", "0"}:
149
+ return False
150
+ return None
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Rediscovery study
155
+ # ---------------------------------------------------------------------------
156
+ @dataclass(frozen=True)
157
+ class StudyShowcase:
158
+ """The rediscovery study, as committed in ``benchmarks/study/``.
159
+
160
+ Replaces the earlier six-cohort validation, whose single positive result came
161
+ from a cohort selected by a classifier keyed on the antigen being sought, and
162
+ whose denominator was chosen after seeing which antigens turned out to be
163
+ over-expressed. Neither survives here: cohorts are whole unstratified TCGA
164
+ projects, and the denominators are pre-registered.
165
+ """
166
+
167
+ design: dict[str, Any]
168
+ recall_cascade: dict[str, Any]
169
+ tier_sensitivity: dict[str, Any]
170
+ primary_interval: dict[str, Any]
171
+ #: Outcome counts over the **pre-registered denominator only** (the tiers in
172
+ #: ``design.tiers_in_primary_denominator``), not over every scored pair. Use
173
+ #: :attr:`outcome_counts_every_tier` for the whole panel, and never show
174
+ #: either without saying which one it is.
175
+ outcome_counts: dict[str, int]
176
+ pairs: list[dict[str, Any]]
177
+ set_sizes: dict[str, dict[str, int]]
178
+ surfaceome_size: int
179
+ figures: dict[str, Path]
180
+ #: The indication-specificity permutation null. The strongest claim the
181
+ #: study supports, and until this field existed no generated surface could
182
+ #: render it: it lived only in ``benchmarks/study/RESULTS.md``, absent from
183
+ #: every README, docs page and manuscript.
184
+ specificity_null: dict[str, Any] = field(default_factory=dict)
185
+ #: The abundance- and dispersion-matched decoy null. Its headline is a
186
+ #: negative, which is exactly why it has to travel with the positive one.
187
+ decoy_null: dict[str, Any] = field(default_factory=dict)
188
+ #: Cohorts carrying no panel antigen, run to show what no signal looks like.
189
+ null_calibration: dict[str, Any] = field(default_factory=dict)
190
+
191
+ @property
192
+ def specificity_is_at_its_floor(self) -> bool:
193
+ """Whether the permutation p is the smallest this design can express.
194
+
195
+ Seven antigens give 5,040 orderings, and two of them are prostate, so
196
+ every distinct assignment is enumerated twice: the floor is 2/5040 and
197
+ the observed p sits on it. A surface that prints the p without saying so
198
+ implies a precision the design does not have.
199
+ """
200
+ spec = self.specificity_null
201
+ p_value, floor = spec.get("p_value"), spec.get("p_value_floor")
202
+ if p_value is None or floor is None:
203
+ return False
204
+ return bool(p_value <= floor * 1.000001)
205
+
206
+ @property
207
+ def indication_gap(self) -> dict[str, Any]:
208
+ """The within-antigen difference between own and off indication.
209
+
210
+ The paired contrast, not the two means side by side: each antigen is its
211
+ own control, which is what makes the interval meaningful at this panel
212
+ size.
213
+ """
214
+ return dict(self.null_calibration.get("paired_difference") or {})
215
+
216
+ @property
217
+ def decoy_rows(self) -> list[dict[str, Any]]:
218
+ """Pairs carrying a decoy-null p, best first. The null lives per pair."""
219
+ rows = [p for p in self.pairs if p.get("p_decoy") is not None]
220
+ return sorted(rows, key=lambda r: r["p_decoy"])
221
+
222
+ @property
223
+ def decoy_nominal(self) -> int:
224
+ """Pairs nominally significant against abundance-matched decoys."""
225
+ return sum(1 for r in self.decoy_rows if r["p_decoy"] < 0.05)
226
+
227
+ @property
228
+ def decoy_surviving_correction(self) -> int:
229
+ """Pairs surviving Benjamini-Hochberg across the panel."""
230
+ return sum(
231
+ 1 for r in self.decoy_rows if r.get("p_decoy_bh") is not None and r["p_decoy_bh"] < 0.05
232
+ )
233
+
234
+ @property
235
+ def smallest_attainable_decoy_bh(self) -> float | None:
236
+ """The smallest BH-adjusted p any pair in this panel could have produced.
237
+
238
+ Each pair's decoy p is bounded below by its own stratum size, so the
239
+ panel's best possible adjusted value follows from the design rather than
240
+ from the data. When that bound already exceeds 0.05, "none survives
241
+ correction" was settled before a single number was computed, and
242
+ reporting it as a finding overstates what the experiment could ever have
243
+ shown. The study's headline negative is exactly this case.
244
+ """
245
+ rows = self.decoy_rows
246
+ if not rows:
247
+ return None
248
+ floors = sorted(r["p_decoy_floor"] for r in rows if r.get("p_decoy_floor") is not None)
249
+ if not floors:
250
+ return None
251
+ n = len(rows)
252
+ # Benjamini-Hochberg at its most generous: every pair sits on its floor.
253
+ return min(float(floors[i] * n) / (i + 1) for i in range(len(floors)))
254
+
255
+ @property
256
+ def decoy_negative_was_forced_by_design(self) -> bool:
257
+ """True when no pair could have survived correction whatever the data."""
258
+ bound = self.smallest_attainable_decoy_bh
259
+ return bound is not None and bound >= 0.05
260
+
261
+ @property
262
+ def surfaced(self) -> list[dict[str, Any]]:
263
+ """Pairs that reached the candidate shortlist, best rank first."""
264
+ ranked = [p for p in self.pairs if isinstance(p.get("rank"), int)]
265
+ return sorted(ranked, key=lambda p: p["rank"])
266
+
267
+ @property
268
+ def headline(self) -> dict[str, Any] | None:
269
+ """The best-ranked surfaced antigen."""
270
+ surfaced = self.surfaced
271
+ return surfaced[0] if surfaced else None
272
+
273
+ @property
274
+ def primary_tiers(self) -> tuple[str, ...]:
275
+ """The regulatory tiers the headline denominator was pre-registered on."""
276
+ return tuple(self.design.get("tiers_in_primary_denominator") or ())
277
+
278
+ @property
279
+ def outcome_counts_every_tier(self) -> dict[str, int]:
280
+ """The same four outcomes over every scored pair.
281
+
282
+ Counted from :attr:`pairs` rather than read from the artifact, because
283
+ the artifact records only the primary-denominator counts and a reader
284
+ comparing them against the pair table needs the other frame too.
285
+ """
286
+ counts: dict[str, int] = {}
287
+ for pair in self.pairs:
288
+ outcome = str(pair.get("outcome_class") or "")
289
+ if outcome:
290
+ counts[outcome] = counts.get(outcome, 0) + 1
291
+ return counts
292
+
293
+ @property
294
+ def n_scored(self) -> int:
295
+ """Antigen-cohort pairs in the scored panel."""
296
+ return len(self.pairs)
297
+
298
+ @property
299
+ def n_projects(self) -> int:
300
+ """TCGA projects the study covers."""
301
+ return len(self.set_sizes)
302
+
303
+ def recall_row(self, denominator: str = "all") -> dict[str, Any]:
304
+ """One denominator's rates across every reported cutoff."""
305
+ block = self.recall_cascade.get(denominator) or {}
306
+ return {
307
+ "median_shortlist": block.get("median_shortlist_size"),
308
+ "at_k": {k: v.get("wilson", {}) for k, v in (block.get("at_k") or {}).items()},
309
+ }
310
+
311
+ def rows(self) -> list[dict[str, Any]]:
312
+ """One display-ready row per antigen-cohort pair.
313
+
314
+ Every row carries the size of the set its rank was taken within, because
315
+ a rank without it cannot be interpreted, and the outcome class, because
316
+ an antigen the instrument could not see and one the ranking placed low
317
+ are different findings.
318
+ """
319
+ out: list[dict[str, Any]] = []
320
+ for pair in self.pairs:
321
+ out.append(
322
+ {
323
+ "antigen": pair.get("symbol", "—"),
324
+ "cohort": str(pair.get("project", "")).removeprefix("TCGA-"),
325
+ "agent": pair.get("agent", ""),
326
+ "tier": pair.get("tier", ""),
327
+ "outcome": pair.get("outcome_class", ""),
328
+ "log2fc": _as_float(pair.get("log2fc")),
329
+ "padj": _as_float(pair.get("padj")),
330
+ "rank": pair.get("rank"),
331
+ "shortlist": pair.get("shortlist_size"),
332
+ "counterfactual_rank": pair.get("counterfactual_rank"),
333
+ "eligible": pair.get("n_eligible"),
334
+ "why": pair.get("reason", ""),
335
+ }
336
+ )
337
+ return sorted(out, key=lambda r: (r["rank"] is None, r["rank"] or 0, r["antigen"]))
338
+
339
+
340
+ def load_study(root: Path | None = None) -> StudyShowcase | None:
341
+ """Load the rediscovery study results.
342
+
343
+ Args:
344
+ root: Optional explicit ``benchmarks/`` directory; discovered when omitted.
345
+
346
+ Returns:
347
+ A :class:`StudyShowcase`, or ``None`` when the results are absent.
348
+ """
349
+ base = (root or benchmarks_root() or Path()) / _STUDY_SUBDIR
350
+ data = _read_json(base / "results.json")
351
+ if data is None:
352
+ return None
353
+
354
+ figures: dict[str, Path] = {}
355
+ fig_dir = base / "figures"
356
+ if fig_dir.is_dir():
357
+ figures = {p.stem: p for p in sorted(fig_dir.glob("*.png"))}
358
+
359
+ return StudyShowcase(
360
+ design=dict(data.get("design") or {}),
361
+ recall_cascade=dict(data.get("recall_cascade") or {}),
362
+ tier_sensitivity=dict(data.get("tier_sensitivity") or {}),
363
+ primary_interval=dict(data.get("primary_interval") or {}),
364
+ outcome_counts=dict(data.get("outcome_class_counts") or {}),
365
+ pairs=list(data.get("pairs") or []),
366
+ set_sizes=dict(data.get("set_sizes_by_cohort") or {}),
367
+ surfaceome_size=int(data.get("surfaceome_size") or 0),
368
+ figures=figures,
369
+ specificity_null=dict(data.get("specificity_null") or {}),
370
+ decoy_null=dict(data.get("decoy_null") or {}),
371
+ null_calibration=dict(data.get("null_calibration") or {}),
372
+ )
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # Designer benchmark
377
+ # ---------------------------------------------------------------------------
378
+ @dataclass(frozen=True)
379
+ class BinderDesign:
380
+ """One designed binder, joined across metrics, developability and embedding."""
381
+
382
+ binder_id: str
383
+ iptm: float | None = None
384
+ pae_interaction: float | None = None
385
+ target_uniprot: str = ""
386
+ validator_name: str = ""
387
+ validator_version: str = ""
388
+ complex_cif: Path | None = None
389
+ fasta: Path | None = None
390
+ developability: dict[str, float] = field(default_factory=dict)
391
+ pc1: float | None = None
392
+ pc2: float | None = None
393
+
394
+ @property
395
+ def sequence(self) -> str | None:
396
+ """Read the designed sequence from the committed FASTA, if present."""
397
+ if self.fasta is None or not self.fasta.is_file():
398
+ return None
399
+ try:
400
+ lines = self.fasta.read_text(encoding="utf-8").splitlines()
401
+ except OSError:
402
+ return None
403
+ return "".join(ln.strip() for ln in lines if ln and not ln.startswith(">")) or None
404
+
405
+
406
+ @dataclass(frozen=True)
407
+ class DesignerShowcase:
408
+ """The designer benchmark, as committed in ``benchmarks/designer_benchmark/``."""
409
+
410
+ generated_utc: str
411
+ bindsight_version: str
412
+ backend: str
413
+ gpu: str
414
+ validator: str
415
+ n_trajectories: int
416
+ #: ``True`` mock backend, ``False`` real GPU run, ``None`` when the artifact
417
+ #: never stated it. Unknown is not "real": a benchmark that does not record
418
+ #: its own provenance cannot be advertised as a genuine GPU result.
419
+ is_mock: bool | None
420
+ targets: list[str]
421
+ designers: list[dict[str, Any]]
422
+ binders: list[BinderDesign]
423
+
424
+ @property
425
+ def scored(self) -> list[BinderDesign]:
426
+ """Binders that carry a validator ipTM, best first."""
427
+ have = [b for b in self.binders if b.iptm is not None]
428
+ return sorted(have, key=lambda b: b.iptm or 0.0, reverse=True)
429
+
430
+ @property
431
+ def best(self) -> BinderDesign | None:
432
+ """The highest-ipTM design."""
433
+ ranked = self.scored
434
+ return ranked[0] if ranked else None
435
+
436
+ @property
437
+ def success_rate(self) -> float | None:
438
+ """Fraction of designs at or above the ipTM 0.65 criterion, as recorded.
439
+
440
+ **Withdrawn as a measure of design quality.** The rate is a real count
441
+ of a real artifact, and it is reported for comparability with published
442
+ de novo work — but a paired control folded each design beside a shuffle
443
+ of its own sequence, and the shuffles cleared the same bar more often
444
+ (50% against 30%; paired difference -0.043, 95% CI -0.142 to +0.054).
445
+
446
+ Any surface rendering this must carry that with it, adjacently, and
447
+ ``bindsight.report.web`` does. It is deliberately not a headline
448
+ statistic anywhere: a caveat underneath a number does not travel with
449
+ the number.
450
+
451
+ See ``benchmarks/calibration/``.
452
+ """
453
+ for d in self.designers:
454
+ rate = _as_float(d.get("success_rate"))
455
+ if rate is not None:
456
+ return rate
457
+ return None
458
+
459
+ @property
460
+ def n_success(self) -> int | None:
461
+ """Designs at or above the criterion, as the artifact counts them."""
462
+ for d in self.designers:
463
+ value = d.get("n_success")
464
+ if value is not None:
465
+ return int(value)
466
+ return None
467
+
468
+ @property
469
+ def success_interval(self) -> tuple[float, float] | None:
470
+ """The reported interval around the success rate, if the artifact has one.
471
+
472
+ Clustered over backbones, because designs sharing an RFdiffusion
473
+ trajectory are not independent trials. A page that prints the rate
474
+ without it claims a precision twenty designs from ten backbones do not
475
+ carry.
476
+ """
477
+ for d in self.designers:
478
+ low = _as_float(d.get("success_ci_low"))
479
+ high = _as_float(d.get("success_ci_high"))
480
+ if low is not None and high is not None:
481
+ return (low, high)
482
+ return None
483
+
484
+ @property
485
+ def mean_iptm(self) -> float | None:
486
+ """Mean ipTM as the benchmark artifact reports it."""
487
+ for d in self.designers:
488
+ value = _as_float(d.get("mean_iptm"))
489
+ if value is not None:
490
+ return value
491
+ return None
492
+
493
+ @property
494
+ def n_designs(self) -> int:
495
+ """Total number of designs in the benchmark."""
496
+ return len(self.binders)
497
+
498
+ def with_structures(self) -> list[BinderDesign]:
499
+ """Designs whose real predicted complex structure is available."""
500
+ return [b for b in self.scored if b.complex_cif is not None]
501
+
502
+
503
+ def load_designer_benchmark(root: Path | None = None) -> DesignerShowcase | None:
504
+ """Load the designer benchmark and join the per-binder artifacts.
505
+
506
+ Args:
507
+ root: Optional explicit ``benchmarks/`` directory; discovered when omitted.
508
+
509
+ Returns:
510
+ A :class:`DesignerShowcase`, or ``None`` when the results are absent.
511
+ """
512
+ base = (root or benchmarks_root() or Path()) / _DESIGNER_SUBDIR
513
+ data = _read_json(base / "results.json")
514
+ if data is None:
515
+ return None
516
+
517
+ binder_dir = base / "binders"
518
+ dev_by_id: dict[str, dict[str, float]] = {}
519
+ for row in _read_tsv(binder_dir / "developability.tsv"):
520
+ bid = row.get("binder_id")
521
+ if not bid:
522
+ continue
523
+ dev_by_id[bid] = {
524
+ k: v
525
+ for k, v in ((k, _as_float(v)) for k, v in row.items() if k != "binder_id")
526
+ if v is not None
527
+ }
528
+
529
+ coords: dict[str, tuple[float | None, float | None]] = {}
530
+ for row in _read_tsv(binder_dir / "embedding_coords.tsv"):
531
+ bid = row.get("binder_id")
532
+ if bid:
533
+ coords[bid] = (_as_float(row.get("pc1")), _as_float(row.get("pc2")))
534
+
535
+ binders: list[BinderDesign] = []
536
+ for row in _read_jsonl(binder_dir / "metrics.jsonl"):
537
+ bid = str(row.get("binder_id") or "")
538
+ if not bid:
539
+ continue
540
+ cif = binder_dir / f"{bid}_complex.cif"
541
+ fasta = binder_dir / f"{bid}.fasta"
542
+ pc1, pc2 = coords.get(bid, (None, None))
543
+ binders.append(
544
+ BinderDesign(
545
+ binder_id=bid,
546
+ iptm=_as_float(row.get("iptm")),
547
+ pae_interaction=_as_float(row.get("pae_interaction")),
548
+ target_uniprot=str(row.get("target_uniprot") or ""),
549
+ validator_name=str(row.get("validator_name") or ""),
550
+ validator_version=str(row.get("validator_version") or ""),
551
+ complex_cif=cif if cif.is_file() else None,
552
+ fasta=fasta if fasta.is_file() else None,
553
+ developability=dev_by_id.get(bid, {}),
554
+ pc1=pc1,
555
+ pc2=pc2,
556
+ )
557
+ )
558
+
559
+ return DesignerShowcase(
560
+ generated_utc=str(data.get("generated_utc", "")),
561
+ bindsight_version=str(data.get("bindsight_version", "")),
562
+ backend=str(data.get("backend", "")),
563
+ gpu=str(data.get("gpu", "")),
564
+ validator=str(data.get("validator", "")),
565
+ n_trajectories=int(data.get("n_trajectories") or 0),
566
+ is_mock=_as_optional_bool(data.get("is_mock")),
567
+ targets=[str(t) for t in (data.get("targets") or [])],
568
+ designers=list(data.get("designers") or []),
569
+ binders=binders,
570
+ )
571
+
572
+
573
+ # ---------------------------------------------------------------------------
574
+ # Headline figures shared by the app and the docs site
575
+ # ---------------------------------------------------------------------------
576
+ @dataclass(frozen=True)
577
+ class Headline:
578
+ """One published number, with the label and context it must be shown with."""
579
+
580
+ value: str
581
+ label: str
582
+ detail: str
583
+
584
+
585
+ def headline_stats() -> list[Headline]:
586
+ """Derive the landing-page numbers from the committed results.
587
+
588
+ Every value is read from ``benchmarks/`` rather than written by hand, so the
589
+ marketing surface cannot overstate what the benchmarks actually show.
590
+
591
+ Returns:
592
+ Zero to four :class:`Headline` entries, depending on what is available.
593
+ """
594
+ stats: list[Headline] = []
595
+
596
+ study = load_study()
597
+ if study is not None:
598
+ top = study.headline
599
+ if top is not None:
600
+ stats.append(
601
+ Headline(
602
+ value=f"rank {top['rank']}",
603
+ label=f"{top['symbol']} surfaced",
604
+ detail=(
605
+ f"{str(top['project']).removeprefix('TCGA-')} · "
606
+ f"of {top['shortlist_size']} candidates · "
607
+ f"log2fc {top['log2fc']:.2f}"
608
+ ),
609
+ )
610
+ )
611
+ if study.n_projects:
612
+ stats.append(
613
+ Headline(
614
+ value=f"{study.n_scored} pairs",
615
+ label=f"across {study.n_projects} TCGA cohorts",
616
+ detail="whole unstratified projects, paired on patient",
617
+ )
618
+ )
619
+
620
+ # The fourth card used to be `success@0.65`, with the withdrawal riding in
621
+ # its caption. That is the same shape as a retracted headline on a front
622
+ # page: the number is what a reader takes away, and a caveat underneath does
623
+ # not travel with it. The rate is still reported in full on the Real results
624
+ # page, beside the control that withdrew it.
625
+ designer = load_designer_benchmark()
626
+ if designer is not None and designer.n_designs:
627
+ stats.append(
628
+ Headline(
629
+ value=f"{designer.n_designs} binders",
630
+ label="designed and folded",
631
+ detail=(
632
+ f"on {designer.gpu or 'a free GPU'}, structures committed; "
633
+ "interface confidence is a triage order, not binding evidence"
634
+ ),
635
+ )
636
+ )
637
+
638
+ # The study's one positive result, which no surface carried until now.
639
+ if study is not None:
640
+ gap = study.indication_gap
641
+ if gap.get("point") is not None and gap.get("low") is not None:
642
+ stats.append(
643
+ Headline(
644
+ value=f"{gap['point']:.2f}",
645
+ label="indication-specific gap",
646
+ detail=(
647
+ f"95% CI {gap['low']:.2f}–{gap['high']:.2f}, excludes zero — "
648
+ "antigens rank higher in the cancer they are actually used in"
649
+ ),
650
+ )
651
+ )
652
+
653
+ return stats