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
bindsight/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # `bindsight/` — the package
2
+
3
+ Over 75 modules. The pipeline runs in stages, and the directories follow them.
4
+
5
+ ## The discovery half (CPU, no GPU)
6
+
7
+ | Module | What it does |
8
+ |---|---|
9
+ | [`io/`](io/) | Readers and writers: Parquet, FASTA, PDB, mmCIF, manifests, and the NIH/GDC and cBioPortal clients that fetch a cohort |
10
+ | [`deg/`](deg/) | Differential expression. A pydeseq2 wrapper with a cache key covering the library version, because an upgraded DESeq2 moves log2 fold changes |
11
+ | [`targets/`](targets/) | Open Targets client, ENSG→UniProt resolution, and the GTEx normal-tissue safety gate |
12
+ | [`surfaceome/`](surfaceome/) | Which proteins reach the cell surface: the SURFY list plus a UniProt curated-membrane extension, vendored so a run never depends on a live fetch |
13
+ | [`structures/`](structures/) | AlphaFoldDB structures, pLDDT disorder gating, UniProt topology |
14
+ | [`epitopes/`](epitopes/) | SURFACE-Bind targetable-site lookup |
15
+
16
+ ## The design half (GPU, via a backend)
17
+
18
+ | Module | What it does |
19
+ |---|---|
20
+ | [`design/`](design/) | The designer plugin interface, developability scoring, ESM-2 embeddings |
21
+ | [`runners/`](runners/) | Where GPU work goes: Kaggle, Modal, local Docker, Colab. See the runner status table in the [root README](../README.md) — they do not carry equal evidence |
22
+ | [`validate/`](validate/) | Boltz-2 by default; Chai-1r and AF2-initial-guess behind flags |
23
+ | [`rank/`](rank/) | Multi-objective composite scoring of validated binders |
24
+
25
+ ## Across both
26
+
27
+ | Module | What it does |
28
+ |---|---|
29
+ | [`pipelines/`](pipelines/) | The discovery orchestrator, and the caveats it attaches to its own output |
30
+ | [`provenance/`](provenance/) | PROV-O JSON-LD emission — the audit trail from a binder back to the cohort |
31
+ | [`export/`](export/) | The RO-Crate exporter — that audit trail packaged as a citable deposit |
32
+ | [`benchmark/`](benchmark/) | The scoring harnesses: rediscovery study, designer benchmark, and the statistics behind every published interval |
33
+ | [`report/`](report/) | The HTML report and the web interface |
34
+ | [`config.py`](config.py) | Pydantic run-configuration models — the single definition of what a run config may contain |
35
+ | [`cli.py`](cli.py) | The Click entry point for every `bindsight` command |
36
+ | [`cost.py`](cost.py) | GPU cost estimation, so `--dry-run` can price a job before it runs |
37
+ | [`plugins.py`](plugins.py) | Entry-point loading for third-party designers and validators |
38
+
39
+ ## A note on what lives where
40
+
41
+ Statistics live in [`benchmark/statistics.py`](benchmark/statistics.py), not
42
+ scattered through the reporting code, because every published interval has to
43
+ come from one implementation that is tested once. If you are checking how a
44
+ number was computed, start there.
bindsight/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """bindsight — a reproducible bridge from RNA-seq to de novo protein binder design.
4
+
5
+ See https://github.com/mikhaeelatefrizk/bindsight for documentation.
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ try:
11
+ __version__ = version("bindsight")
12
+ except PackageNotFoundError:
13
+ __version__ = "0.0.0+unknown"
14
+
15
+ __all__ = ["__version__"]
@@ -0,0 +1,71 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Rediscovery benchmarking against the held-out known-antigen set.
4
+
5
+ Scores one or more finished ``bindsight`` run directories by how well they
6
+ resurface the literature-validated known antigens in ``benchmarks/known.tsv``
7
+ (rank of each known antigen, recall@k, enrichment), and renders a side-by-side
8
+ HTML report. This is the implementation behind the ``bindsight benchmark``
9
+ command referenced in ``docs/use-cases.md``.
10
+
11
+ Every public name here except ``DEFAULT_KS`` is resolved lazily, and
12
+ ``DEFAULT_KS`` is the reason the rest are. Importing them all eagerly pulled
13
+ :mod:`bindsight.benchmark.core`, and therefore pandas, into anything that
14
+ touched this package by any route -- including ``from
15
+ bindsight.benchmark.defaults import DEFAULT_KS``, which needs nothing but a
16
+ tuple. So that one name is bound here directly and costs nothing; everything
17
+ that needs pandas waits until it is asked for. :mod:`bindsight.cli` reads that tuple at import time, so a plain ``pip
18
+ install bindsight`` (pandas lives in the ``discover`` extra) shipped a command
19
+ that raised ``ModuleNotFoundError`` before ``--version`` could print.
20
+
21
+ The lazy hook must raise ``AttributeError`` (never ``ImportError``) for unknown
22
+ names, for the reason :mod:`bindsight.report` gives: ``from bindsight.benchmark
23
+ import core`` consults ``__getattr__`` before the submodule import machinery,
24
+ so swallowing the error here would break every such import in the codebase.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from bindsight.benchmark.defaults import DEFAULT_KS
32
+
33
+ if TYPE_CHECKING: # pragma: no cover - typing only
34
+ from bindsight.benchmark.core import (
35
+ KnownAntigen,
36
+ RunScore,
37
+ load_known_antigens,
38
+ render_benchmark_html,
39
+ run_benchmark,
40
+ score_run,
41
+ )
42
+
43
+ # Spelled out as a literal, because mypy reads ``__all__`` statically and a list
44
+ # built with a splat tells it nothing -- it then reports every re-export as not
45
+ # explicitly exported. ``_LAZY`` is derived from it rather than written twice.
46
+ __all__ = [
47
+ "DEFAULT_KS",
48
+ "KnownAntigen",
49
+ "RunScore",
50
+ "load_known_antigens",
51
+ "render_benchmark_html",
52
+ "run_benchmark",
53
+ "score_run",
54
+ ]
55
+
56
+ #: Everything in ``__all__`` except the one name that costs nothing to import.
57
+ _LAZY = frozenset(__all__) - {"DEFAULT_KS"}
58
+
59
+
60
+ def __getattr__(name: str) -> Any:
61
+ """Resolve the scoring API on first access (PEP 562)."""
62
+ if name in _LAZY:
63
+ import bindsight.benchmark.core as _core
64
+
65
+ return getattr(_core, name)
66
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
67
+
68
+
69
+ def __dir__() -> list[str]:
70
+ """Keep ``dir()`` honest about the lazily-exported names."""
71
+ return sorted({*globals(), *__all__})
@@ -0,0 +1,497 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Rediscovery-benchmark scoring + report rendering.
4
+
5
+ Given one or more finished run directories (each with
6
+ ``targets/candidates.parquet`` from ``bindsight discover``) and a known-antigen
7
+ table (``benchmarks/known.tsv``), compute, per run:
8
+
9
+ - the rank of each known antigen in the candidate shortlist (by UniProt),
10
+ - whether it was found at all, and whether it landed in the top-k,
11
+ - recall@k aggregated across the run's *on-indication* known antigens.
12
+
13
+ Then render a self-contained HTML report comparing the runs side by side. The
14
+ math is intentionally simple and transparent so the benchmark is defensible:
15
+ recall@k is ``#{on-indication known antigens with rank ≤ k} /
16
+ #{on-indication known antigens}``.
17
+
18
+ Rediscovery is indication-scoped: a run of a colorectal cohort surfacing a
19
+ breast antigen has not rediscovered anything, so a known antigen is credited
20
+ only when its own ``tumor_type`` is the run's indication. Known antigens from
21
+ other indications that do appear in a shortlist are reported separately as
22
+ cross-indication cross-reactivity and never enter recall@k. A run whose
23
+ indication is not supplied is scored over the whole known set and labelled as
24
+ such, because the gate cannot be applied without it.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import html
30
+ import logging
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+
34
+ import pandas as pd
35
+
36
+ # Re-exported, not defined here. This module imports pandas at module scope and
37
+ # the CLI needs the value at import time, so defining it here made a bare
38
+ # install unable to run `bindsight --version`. Callers that already have
39
+ # pandas may keep importing it from either place.
40
+ from bindsight.benchmark.defaults import DEFAULT_KS
41
+
42
+ LOG = logging.getLogger(__name__)
43
+
44
+ __all__ = ["DEFAULT_KS"]
45
+
46
+
47
+ def portable_path(path: Path | str) -> str:
48
+ """Render *path* for a published artifact.
49
+
50
+ Absolute paths from the machine that produced a run are not reproducible
51
+ and leak the operating environment, so record a repo-relative POSIX path
52
+ where we can and fall back to the bare filename where we cannot.
53
+ """
54
+ resolved = Path(path).resolve()
55
+ here = Path(__file__).resolve()
56
+ for parent in [here.parent, *here.parents]:
57
+ if (parent / "pyproject.toml").exists():
58
+ try:
59
+ return resolved.relative_to(parent).as_posix()
60
+ except ValueError:
61
+ break
62
+ return resolved.name
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class KnownAntigen:
67
+ """One held-out known antigen we expect a rediscovery run to surface."""
68
+
69
+ symbol: str
70
+ uniprot: str
71
+ tumor_type: str = ""
72
+ disease: str = ""
73
+ expected_direction: str = "up"
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class _Hit:
78
+ """A known antigen's match in a run's candidate shortlist."""
79
+
80
+ rank: int
81
+ log2fc: float | None
82
+ padj: float | None
83
+ symbol: str | None
84
+
85
+
86
+ @dataclass
87
+ class RunScore:
88
+ """Per-run rediscovery score over the known-antigen set.
89
+
90
+ ``per_antigen`` holds one row per *on-indication* known antigen (the
91
+ recall@k denominator); ``cross_indication`` holds the known antigens from
92
+ other indications that happen to appear in the shortlist, which are
93
+ cross-reactivity observations rather than rediscoveries. ``recall_basis``
94
+ records which of the two regimes produced ``recall_at``:
95
+ ``"on_indication"``, ``"indication_unknown"`` (no indication supplied for
96
+ the run), ``"no_known_antigen_for_indication"`` (nothing to score, so
97
+ ``recall_at`` is left empty rather than reported as zero) or
98
+ ``"candidates_unavailable"`` (the run's ``candidates.parquet`` was missing,
99
+ empty or unreadable -- the same rule applies, and used not to: every antigen
100
+ came back ``found=False`` and every cutoff reported 0%, which reads as a
101
+ ranking that missed everything rather than a run that was never read).
102
+ """
103
+
104
+ run_name: str
105
+ run_dir: str
106
+ tumor_type: str | None = None
107
+ recall_basis: str = "indication_unknown"
108
+ per_antigen: list[dict[str, object]] = field(default_factory=list) # on-indication antigens
109
+ cross_indication: list[dict[str, object]] = field(default_factory=list)
110
+ recall_at: dict[int, float] = field(default_factory=dict)
111
+ n_known: int = 0
112
+ n_on_indication: int = 0
113
+ n_found: int = 0
114
+ n_cross_indication: int = 0
115
+ n_candidates: int = 0
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Loading
120
+ # ---------------------------------------------------------------------------
121
+ def load_known_antigens(path: Path | str) -> list[KnownAntigen]:
122
+ """Load ``known.tsv`` into a list of :class:`KnownAntigen`.
123
+
124
+ Requires at least ``symbol`` and ``uniprot`` columns; ``tumor_type``,
125
+ ``disease`` and ``expected_direction`` are optional.
126
+ """
127
+ df = pd.read_csv(path, sep="\t", dtype=str).fillna("")
128
+ missing = {"symbol", "uniprot"} - set(df.columns)
129
+ if missing:
130
+ raise ValueError(f"{path}: known-antigen table missing columns {sorted(missing)}")
131
+ return [
132
+ KnownAntigen(
133
+ symbol=row["symbol"],
134
+ uniprot=row["uniprot"],
135
+ tumor_type=row.get("tumor_type", ""),
136
+ disease=row.get("disease", ""),
137
+ expected_direction=row.get("expected_direction", "up"),
138
+ )
139
+ for _, row in df.iterrows()
140
+ ]
141
+
142
+
143
+ def _load_candidates(run_dir: Path) -> pd.DataFrame | None:
144
+ path = run_dir / "targets" / "candidates.parquet"
145
+ if not path.exists() or path.stat().st_size == 0:
146
+ LOG.warning("no candidates.parquet in %s", run_dir)
147
+ return None
148
+ try:
149
+ return pd.read_parquet(path)
150
+ except Exception as e:
151
+ LOG.warning("failed to read %s: %s", path, e)
152
+ return None
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Scoring
157
+ # ---------------------------------------------------------------------------
158
+ def _matches_indication(antigen_tumor_type: str, run_tumor_type: str) -> bool:
159
+ """True when a known antigen's own indication is the run's indication."""
160
+ return antigen_tumor_type.strip().casefold() == run_tumor_type.strip().casefold()
161
+
162
+
163
+ def score_run(
164
+ run_dir: Path | str,
165
+ known: list[KnownAntigen],
166
+ *,
167
+ ks: tuple[int, ...] = DEFAULT_KS,
168
+ run_name: str | None = None,
169
+ tumor_type: str | None = None,
170
+ ) -> RunScore:
171
+ """Score one run directory against the known-antigen set.
172
+
173
+ A known antigen is matched to a candidate row by UniProt accession
174
+ (``candidates.uniprot_id``). Its ``rank`` is taken from the candidate table
175
+ (the discover stage's 1-based rank). If the antigen never appears in the
176
+ candidates it is recorded as not-found (rank ``None``).
177
+
178
+ Protein identity alone is not rediscovery: an antigen is credited (and
179
+ enters recall@k) only when its ``tumor_type`` is also the run's indication.
180
+ Matches from other indications are collected in
181
+ :attr:`RunScore.cross_indication` instead.
182
+
183
+ Args:
184
+ run_dir: a finished run directory containing ``targets/candidates.parquet``.
185
+ known: the known-antigen set to score against.
186
+ ks: top-k cutoffs for recall@k.
187
+ run_name: display name for the run (defaults to the directory name).
188
+ tumor_type: the run's indication, using the same vocabulary as the
189
+ known table's ``tumor_type`` column (e.g. ``"BRCA"``). When
190
+ ``None`` the indication gate cannot be applied; every known antigen
191
+ is then scored and ``recall_basis`` says so.
192
+ """
193
+ run_dir = Path(run_dir)
194
+ name = run_name or run_dir.name
195
+ cands = _load_candidates(run_dir)
196
+
197
+ # Build a uniprot -> _Hit lookup from the candidates.
198
+ rank_by_uniprot: dict[str, _Hit] = {}
199
+ n_candidates = 0
200
+ candidates_available = False
201
+ # A frame that loaded but carries no accession column is as unusable as one
202
+ # that never loaded: neither can say whether an antigen was ranked. Narrowed
203
+ # in the ``if`` rather than stored in a flag first, because a flag is opaque
204
+ # to the type checker and ``cands`` is Optional on the very next line.
205
+ if cands is not None and "uniprot_id" in cands.columns:
206
+ candidates_available = True
207
+ n_candidates = int(cands["uniprot_id"].notna().sum())
208
+ ranked = cands.dropna(subset=["uniprot_id"]).copy()
209
+ # Prefer an explicit 'rank' column; otherwise rank by row order.
210
+ if "rank" not in ranked.columns:
211
+ ranked = ranked.reset_index(drop=True)
212
+ ranked["rank"] = range(1, len(ranked) + 1)
213
+ for _, r in ranked.iterrows():
214
+ uid = str(r["uniprot_id"])
215
+ if uid not in rank_by_uniprot: # keep best (first) rank per uniprot
216
+ sym = r.get("symbol")
217
+ rank_by_uniprot[uid] = _Hit(
218
+ rank=int(r["rank"]),
219
+ log2fc=_f(r.get("log2fc")),
220
+ padj=_f(r.get("padj")),
221
+ symbol=str(sym) if pd.notna(sym) else None,
222
+ )
223
+
224
+ on_indication: list[KnownAntigen] = []
225
+ off_indication: list[KnownAntigen] = []
226
+ for ka in known:
227
+ on = tumor_type is None or _matches_indication(ka.tumor_type, tumor_type)
228
+ (on_indication if on else off_indication).append(ka)
229
+
230
+ def _row(ka: KnownAntigen, hit: _Hit | None, on: bool | None) -> dict[str, object]:
231
+ return {
232
+ "symbol": ka.symbol,
233
+ "uniprot": ka.uniprot,
234
+ "tumor_type": ka.tumor_type,
235
+ "on_indication": on,
236
+ # ``None``, not ``False``, when there was no shortlist to look in.
237
+ # "We did not find it" and "we could not look" render identically
238
+ # otherwise, and only one of them is a result.
239
+ "found": (hit is not None) if candidates_available else None,
240
+ "rank": hit.rank if hit else None,
241
+ "log2fc": hit.log2fc if hit else None,
242
+ "padj": hit.padj if hit else None,
243
+ **{
244
+ f"in_top_{k}": (hit is not None and hit.rank <= k) if candidates_available else None
245
+ for k in ks
246
+ },
247
+ }
248
+
249
+ per_antigen = [
250
+ _row(ka, rank_by_uniprot.get(ka.uniprot), None if tumor_type is None else True)
251
+ for ka in on_indication
252
+ ]
253
+ n_found = sum(1 for a in per_antigen if a["found"])
254
+ # Off-indication antigens are only worth reporting when they were surfaced;
255
+ # a miss in another cancer type is neither a rediscovery nor a failure.
256
+ cross_indication = [
257
+ _row(ka, hit, False)
258
+ for ka in off_indication
259
+ if (hit := rank_by_uniprot.get(ka.uniprot)) is not None
260
+ ]
261
+
262
+ recall_at: dict[int, float] = {}
263
+ if on_indication and candidates_available:
264
+ for k in ks:
265
+ hits = sum(1 for a in per_antigen if a[f"in_top_{k}"])
266
+ recall_at[k] = hits / len(on_indication)
267
+
268
+ if not candidates_available:
269
+ basis = "candidates_unavailable"
270
+ elif tumor_type is None:
271
+ basis = "indication_unknown"
272
+ elif on_indication:
273
+ basis = "on_indication"
274
+ else:
275
+ basis = "no_known_antigen_for_indication"
276
+
277
+ return RunScore(
278
+ run_name=name,
279
+ run_dir=str(run_dir),
280
+ tumor_type=tumor_type,
281
+ recall_basis=basis,
282
+ per_antigen=per_antigen,
283
+ cross_indication=cross_indication,
284
+ recall_at=recall_at,
285
+ n_known=len(known),
286
+ n_on_indication=len(on_indication),
287
+ n_found=n_found,
288
+ n_cross_indication=len(cross_indication),
289
+ n_candidates=n_candidates,
290
+ )
291
+
292
+
293
+ def _f(v: object) -> float | None:
294
+ if v is None or (isinstance(v, float) and pd.isna(v)):
295
+ return None
296
+ try:
297
+ return float(v) # type: ignore[arg-type]
298
+ except (TypeError, ValueError):
299
+ return None
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Reporting
304
+ # ---------------------------------------------------------------------------
305
+ def render_benchmark_html(
306
+ scores: list[RunScore],
307
+ *,
308
+ ks: tuple[int, ...] = DEFAULT_KS,
309
+ known_source: str = "",
310
+ ) -> str:
311
+ """Render the benchmark scores as a self-contained HTML string."""
312
+ e = html.escape
313
+
314
+ def _cell(v: object) -> str:
315
+ if v is None:
316
+ return "<td class='miss'>—</td>"
317
+ if isinstance(v, bool):
318
+ return f"<td class='{'hit' if v else 'miss'}'>{'✓' if v else '·'}</td>"
319
+ if isinstance(v, float):
320
+ return f"<td>{v:.3g}</td>"
321
+ return f"<td>{e(str(v))}</td>"
322
+
323
+ # Summary table: one row per run, recall@k columns over the on-indication set.
324
+ summary_rows = ""
325
+ for s in scores:
326
+ cells = "".join(
327
+ f"<td>{s.recall_at[k]:.0%}</td>" if k in s.recall_at else "<td class='miss'>n/a</td>"
328
+ for k in ks
329
+ )
330
+ indication = e(s.tumor_type) if s.tumor_type else "<span class='miss'>unknown</span>"
331
+ # A run whose candidate table could not be read has no "found" count and
332
+ # no candidate count. The recall columns already render n/a for it; these
333
+ # two printed a hard 0, which reads as a measured miss.
334
+ unread = s.recall_basis == "candidates_unavailable"
335
+ found_cell = (
336
+ "<td class='miss'>n/a</td>" if unread else f"<td>{s.n_found}/{s.n_on_indication}</td>"
337
+ )
338
+ candidates_cell = "<td class='miss'>n/a</td>" if unread else f"<td>{s.n_candidates}</td>"
339
+ summary_rows += (
340
+ f"<tr><td class='name'>{e(s.run_name)}</td><td>{indication}</td>"
341
+ f"{found_cell}<td>{s.n_cross_indication}</td>"
342
+ f"{candidates_cell}{cells}</tr>"
343
+ )
344
+ recall_headers = "".join(f"<th>recall@{k}</th>" for k in ks)
345
+
346
+ # Per-run detail tables.
347
+ detail_blocks = ""
348
+ topk_headers = "".join(f"<th>top{k}</th>" for k in ks)
349
+ antigen_headers = (
350
+ "<tr><th>antigen</th><th>uniprot</th><th>tumor</th>"
351
+ f"<th>found</th><th>rank</th><th>log2fc</th>{topk_headers}</tr>"
352
+ )
353
+
354
+ def _rank_key(a: dict[str, object]) -> tuple[bool, int]:
355
+ r = a["rank"]
356
+ return (r is None, r if isinstance(r, int) else 0)
357
+
358
+ def _antigen_table(antigens: list[dict[str, object]]) -> str:
359
+ rows = ""
360
+ for a in sorted(antigens, key=_rank_key):
361
+ topk = "".join(_cell(a[f"in_top_{k}"]) for k in ks)
362
+ rows += (
363
+ f"<tr><td class='name'>{e(str(a['symbol']))}</td>"
364
+ f"<td>{e(str(a['uniprot']))}</td><td>{e(str(a['tumor_type']))}</td>"
365
+ f"{_cell(a['found'])}{_cell(a['rank'])}{_cell(a['log2fc'])}{topk}</tr>"
366
+ )
367
+ return f"<table><thead>{antigen_headers}</thead><tbody>{rows}</tbody></table>"
368
+
369
+ for s in scores:
370
+ detail_blocks += (
371
+ f"<h3>{e(s.run_name)}</h3><div class='sub'>{e(portable_path(s.run_dir))}</div>"
372
+ )
373
+ if s.recall_basis == "on_indication":
374
+ detail_blocks += (
375
+ f"<div class='scope'>Scored against the {e(str(s.tumor_type))} known "
376
+ "antigen(s) — the only ones this run can rediscover.</div>"
377
+ )
378
+ elif s.recall_basis == "indication_unknown":
379
+ detail_blocks += (
380
+ "<div class='scope warn'>Indication not supplied for this run, so no "
381
+ "indication gate could be applied: the whole known set is scored and a "
382
+ "match may belong to another cancer type.</div>"
383
+ )
384
+ elif s.recall_basis == "candidates_unavailable":
385
+ detail_blocks += (
386
+ "<div class='scope warn'>This run's candidate table could not be read "
387
+ "(missing, empty or unreadable), so nothing could be looked up and "
388
+ "recall@k is not defined. This is not a rediscovery rate of zero.</div>"
389
+ )
390
+ else:
391
+ detail_blocks += (
392
+ f"<div class='scope warn'>No known antigen is annotated for indication "
393
+ f"{e(str(s.tumor_type))}, so recall@k is not defined for this run.</div>"
394
+ )
395
+ detail_blocks += _antigen_table(s.per_antigen)
396
+ if s.cross_indication:
397
+ detail_blocks += (
398
+ "<h4>Cross-indication cross-reactivity — NOT rediscovery</h4>"
399
+ "<div class='scope warn'>Known antigens of <em>other</em> cancer types that "
400
+ "this run surfaced. They are excluded from recall@k above.</div>"
401
+ + _antigen_table(s.cross_indication)
402
+ )
403
+
404
+ return _HTML_TEMPLATE.format(
405
+ recall_headers=recall_headers,
406
+ summary_rows=summary_rows,
407
+ detail_blocks=detail_blocks,
408
+ known_source=e(known_source),
409
+ n_runs=len(scores),
410
+ )
411
+
412
+
413
+ def run_benchmark(
414
+ run_dirs: list[Path | str],
415
+ known_antigens_path: Path | str,
416
+ *,
417
+ out_html: Path | str,
418
+ ks: tuple[int, ...] = DEFAULT_KS,
419
+ tumor_types: list[str | None] | None = None,
420
+ ) -> tuple[Path, list[RunScore]]:
421
+ """Score every run against the known set and write the HTML report.
422
+
423
+ Args:
424
+ run_dirs: finished run directories to score.
425
+ known_antigens_path: the known-antigen table (``benchmarks/known.tsv``).
426
+ out_html: where to write the report.
427
+ ks: top-k cutoffs for recall@k.
428
+ tumor_types: each run's indication, positionally aligned with
429
+ ``run_dirs``. Runs without one are scored over the whole known set
430
+ and reported as indication-unknown, since rediscovery cannot be
431
+ told apart from cross-indication cross-reactivity without it.
432
+
433
+ Returns ``(out_html_path, scores)``.
434
+
435
+ Raises:
436
+ ValueError: if ``tumor_types`` is given with a different length than
437
+ ``run_dirs``.
438
+ """
439
+ if tumor_types is not None and len(tumor_types) != len(run_dirs):
440
+ raise ValueError(
441
+ f"tumor_types has {len(tumor_types)} entries for {len(run_dirs)} run dir(s)"
442
+ )
443
+ known = load_known_antigens(known_antigens_path)
444
+ indications = tumor_types if tumor_types is not None else [None] * len(run_dirs)
445
+ scores = [
446
+ score_run(rd, known, ks=ks, tumor_type=tt)
447
+ for rd, tt in zip(run_dirs, indications, strict=True)
448
+ ]
449
+ out = Path(out_html)
450
+ out.parent.mkdir(parents=True, exist_ok=True)
451
+ out.write_text(
452
+ render_benchmark_html(scores, ks=ks, known_source=str(known_antigens_path)),
453
+ encoding="utf-8",
454
+ newline="\n",
455
+ )
456
+ LOG.info("wrote %s (%d runs, %d known antigens)", out, len(scores), len(known))
457
+ return out, scores
458
+
459
+
460
+ _HTML_TEMPLATE = """\
461
+ <!doctype html>
462
+ <html lang="en"><head><meta charset="utf-8">
463
+ <title>bindsight rediscovery benchmark</title>
464
+ <style>
465
+ body {{ font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
466
+ margin: 2rem auto; max-width: 980px; color: #1a1a1a; line-height: 1.45; }}
467
+ h1 {{ font-size: 1.5rem; }} h3 {{ margin-top: 1.6rem; }} h4 {{ margin: 1.1rem 0 .2rem; }}
468
+ .sub {{ color: #666; font-size: .8rem; margin-bottom: .3rem; font-family: monospace; }}
469
+ .scope {{ color: #555; font-size: .82rem; margin-bottom: .25rem; }}
470
+ .scope.warn {{ color: #8a4b00; }}
471
+ table {{ border-collapse: collapse; width: 100%; margin: .5rem 0 1.2rem; font-size: .9rem; }}
472
+ th, td {{ border: 1px solid #ddd; padding: .35rem .55rem; text-align: center; }}
473
+ th {{ background: #f4f4f6; }}
474
+ td.name {{ text-align: left; font-weight: 600; }}
475
+ td.hit {{ color: #137333; font-weight: 700; }}
476
+ td.miss {{ color: #b00020; }}
477
+ .foot {{ color: #777; font-size: .8rem; margin-top: 2rem; }}
478
+ </style></head><body>
479
+ <h1>bindsight — rediscovery benchmark</h1>
480
+ <p>How well each run resurfaces the held-out known antigens of <em>its own</em>
481
+ indication. Known set: <code>{known_source}</code> · {n_runs} run(s).
482
+ <strong>recall@k</strong> = fraction of the run's on-indication known antigens
483
+ ranked in the top-k. A known antigen of another cancer type is never counted as
484
+ a rediscovery; where a run surfaces one it is listed under
485
+ <em>cross-indication cross-reactivity</em>. Runs with no declared indication are
486
+ marked <em>unknown</em> and are scored over the whole known set, which cannot
487
+ distinguish rediscovery from cross-reactivity.</p>
488
+ <h2>Summary</h2>
489
+ <table><thead><tr><th>run</th><th>indication</th><th>on-indication found</th>
490
+ <th>cross-indication</th><th>candidates</th>{recall_headers}</tr></thead>
491
+ <tbody>{summary_rows}</tbody></table>
492
+ <h2>Per-antigen detail</h2>
493
+ {detail_blocks}
494
+ <p class="foot">Generated by <code>bindsight benchmark</code>. Known antigens and
495
+ literature-validated binders: see <code>benchmarks/PROVENANCE.md</code>.</p>
496
+ </body></html>
497
+ """
@@ -0,0 +1,28 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Benchmark constants that cost nothing to import.
4
+
5
+ ``DEFAULT_KS`` lived in :mod:`bindsight.benchmark.core`, which imports pandas at
6
+ module scope. :mod:`bindsight.cli` needs the value at import time -- it is
7
+ interpolated into a ``--k`` help string, which Click evaluates when the command
8
+ is defined -- so every invocation of ``bindsight``, including ``--version`` and
9
+ ``--help``, pulled pandas in. pandas is declared under the ``discover`` extra
10
+ and not in the base dependencies, so a plain ``pip install bindsight`` produced
11
+ a command that could not start.
12
+
13
+ CI never saw it: every job installs ``.[dev,discover,report]``.
14
+
15
+ This module is stdlib-only at module scope and must stay that way.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ #: Rank cutoffs reported by ``bindsight benchmark`` when none are given.
21
+ #:
22
+ #: Named once, here. The CLI shows them in its help text and falls back to them
23
+ #: when ``--k`` is absent, and the scoring functions take them as a default
24
+ #: argument -- three readers of one value, which is three chances for it to be
25
+ #: written out by hand and drift.
26
+ DEFAULT_KS: tuple[int, ...] = (5, 10, 20)
27
+
28
+ __all__ = ["DEFAULT_KS"]