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,624 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Routes and rendering for the bindsight web interface.
4
+
5
+ Five sections, in the order the argument runs:
6
+
7
+ 1. **Overview** — what it is, and what it has and has not shown.
8
+ 2. **Evidence** — the study, the controls, the binders, the provenance chain.
9
+ 3. **Try it** — the demo, with per-stage progress.
10
+ 4. **Your data** — upload, validate, run.
11
+ 5. **Runs** — browse what is on disk.
12
+
13
+ The data layer is :mod:`bindsight.report.showcase`, unchanged. This module owns
14
+ presentation only, and enforces the two presentation rules the previous
15
+ interface broke: a figure appears with what makes it interpretable, and an
16
+ absent measurement renders as an em dash rather than as zero.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import threading
24
+ import uuid
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from fastapi import FastAPI, Request
30
+ from fastapi.responses import HTMLResponse, JSONResponse, Response
31
+ from fastapi.staticfiles import StaticFiles
32
+ from fastapi.templating import Jinja2Templates
33
+ from markupsafe import Markup
34
+
35
+ LOG = logging.getLogger(__name__)
36
+
37
+ HERE = Path(__file__).resolve().parent
38
+ STATIC = HERE / "static"
39
+ TEMPLATES = HERE / "templates"
40
+
41
+ # Presentation rules live in `bindsight.report.format` so the standalone HTML
42
+ # report applies the same ones. Re-exported here because the templates and the
43
+ # tests reach for them through this module.
44
+ from bindsight.report.format import ( # noqa: E402
45
+ ABSENT,
46
+ fmt,
47
+ fmt_int,
48
+ fmt_p,
49
+ interval,
50
+ )
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Background jobs
55
+ # ---------------------------------------------------------------------------
56
+ @dataclass
57
+ class Job:
58
+ """A pipeline run in progress, and what it has done so far.
59
+
60
+ The Streamlit app showed a single indeterminate spinner for a download, a
61
+ DESeq2 fit, a surfaceome filter and two API sweeps -- minutes of a motionless
62
+ page on a cold cache, with no stage name and no way to tell a slow run from a
63
+ stuck one.
64
+ """
65
+
66
+ id: str
67
+ kind: str
68
+ stages: list[dict[str, str]] = field(default_factory=list)
69
+ state: str = "running"
70
+ error: str = ""
71
+ run_dir: str = ""
72
+
73
+ def step(self, name: str, state: str = "running") -> None:
74
+ """Record a stage, or move one already recorded to a new state."""
75
+ for stage in self.stages:
76
+ if stage["name"] == name:
77
+ stage["state"] = state
78
+ return
79
+ self.stages.append({"name": name, "state": state})
80
+
81
+ def finish_previous(self) -> None:
82
+ """Close whatever is still running.
83
+
84
+ Called when the next stage begins, so a reader can tell a slow run
85
+ from a stuck one -- which a single indeterminate spinner cannot.
86
+ """
87
+ for stage in self.stages:
88
+ if stage["state"] == "running":
89
+ stage["state"] = "done"
90
+
91
+
92
+ _JOBS: dict[str, Job] = {}
93
+ _JOBS_LOCK = threading.Lock()
94
+
95
+
96
+ def _new_job(kind: str) -> Job:
97
+ job = Job(id=uuid.uuid4().hex[:12], kind=kind)
98
+ with _JOBS_LOCK:
99
+ _JOBS[job.id] = job
100
+ return job
101
+
102
+
103
+ def _get_job(job_id: str) -> Job | None:
104
+ with _JOBS_LOCK:
105
+ return _JOBS.get(job_id)
106
+
107
+
108
+ def _tojson(obj: Any) -> Markup:
109
+ """JSON for an HTML attribute, escaped for that context and marked safe.
110
+
111
+ The chart specs are emitted into ``data-spec='...'`` -- a single-quoted
112
+ attribute -- so an apostrophe in the data would close it, and a ``<`` would
113
+ matter the day one of these moves into a ``<script>`` block. Both are
114
+ escaped as JSON unicode escapes, which is still valid JSON and decodes
115
+ back to the same characters, so a reader of the attribute sees exactly
116
+ what was put there.
117
+
118
+ Marked ``Markup`` because the escaping is done here. Leaving it to the
119
+ autoescape that now covers these templates would turn every quote into
120
+ ``&#34;``: correct HTML, and browsers decode it, but it makes the
121
+ attribute unreadable and hides what is in it. ``report/html.py`` makes
122
+ the same trade for the mmCIF it embeds.
123
+ """
124
+ text = json.dumps(obj)
125
+ for raw, escaped in (
126
+ ("<", "\\u003c"),
127
+ (">", "\\u003e"),
128
+ ("&", "\\u0026"),
129
+ ("'", "\\u0027"),
130
+ ):
131
+ text = text.replace(raw, escaped)
132
+ return Markup(text)
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Application
137
+ # ---------------------------------------------------------------------------
138
+ def create_app(*, run_root: Path | None = None) -> Any:
139
+ """Build the FastAPI application.
140
+
141
+ Args:
142
+ run_root: where to look for existing runs. Defaults to ``runs/`` under
143
+ the current working directory.
144
+ """
145
+ from bindsight import __version__
146
+ from bindsight.report import showcase, theme
147
+
148
+ app = FastAPI(title="bindsight", docs_url=None, redoc_url=None)
149
+ app.mount("/static", StaticFiles(directory=str(STATIC)), name="static")
150
+ templates = Jinja2Templates(directory=str(TEMPLATES))
151
+ # Explicit, because Jinja's own default would leave it off. Starlette
152
+ # hands escaping to `select_autoescape()`, which decides by filename and
153
+ # whose default extensions are html/htm/xml -- and every template here is
154
+ # `*.html.j2`, which ends in `.j2`. So the whole interface rendered
155
+ # unescaped: a design table's column names reach `runs.html.j2` through
156
+ # the ValueError pydeseq2_runner raises and the `repr(e)` discover writes
157
+ # into the manifest, which made a `.tsv` from a stranger a script tag on
158
+ # this page. No template here uses `|safe`, so this changes nothing but
159
+ # the escaping. tests/test_web_ui.py holds both halves.
160
+ templates.env.autoescape = True
161
+
162
+ # Everything a template may call. Kept explicit so a template cannot reach
163
+ # into arbitrary application state.
164
+ templates.env.globals.update(
165
+ fmt=fmt,
166
+ fmt_int=fmt_int,
167
+ fmt_p=fmt_p,
168
+ interval=interval,
169
+ ABSENT=ABSENT,
170
+ version=__version__,
171
+ tojson=_tojson,
172
+ citation=theme.citation_line,
173
+ github=theme.GITHUB_URL,
174
+ )
175
+
176
+ runs_root = Path(run_root) if run_root else Path.cwd() / "runs"
177
+
178
+ def page(request: Request, name: str, **ctx: Any) -> HTMLResponse:
179
+ return templates.TemplateResponse(
180
+ request=request, name=name, context={"nav": name.split(".")[0], **ctx}
181
+ )
182
+
183
+ # -- 1. Overview --------------------------------------------------------
184
+ @app.get("/", response_class=HTMLResponse)
185
+ def overview(request: Request) -> HTMLResponse:
186
+ study = showcase.load_study()
187
+ designer = showcase.load_designer_benchmark()
188
+ return page(
189
+ request,
190
+ "overview.html.j2",
191
+ study=study,
192
+ designer=designer,
193
+ have_evidence=study is not None or designer is not None,
194
+ )
195
+
196
+ # -- 2. Evidence --------------------------------------------------------
197
+ @app.get("/evidence", response_class=HTMLResponse)
198
+ def evidence(request: Request) -> HTMLResponse:
199
+ study = showcase.load_study()
200
+ designer = showcase.load_designer_benchmark()
201
+ # Only designs whose predicted complex is actually committed. Offering a
202
+ # design with no structure gives the reader a viewer that stays empty
203
+ # and no way to tell whether that is the design's fault or the page's.
204
+ structures = designer.with_structures() if designer else []
205
+ return page(
206
+ request,
207
+ "evidence.html.j2",
208
+ structures=structures,
209
+ study=study,
210
+ designer=designer,
211
+ paired=_paired_chart(designer),
212
+ nulls=_nulls_chart(study),
213
+ recall=_recall_chart(study),
214
+ )
215
+
216
+ # -- 3. Try it ----------------------------------------------------------
217
+ @app.get("/try", response_class=HTMLResponse)
218
+ def try_it(request: Request) -> HTMLResponse:
219
+ return page(request, "try.html.j2", cohort=_demo_description())
220
+
221
+ @app.post("/try/run")
222
+ def try_run() -> JSONResponse:
223
+ job = _new_job("demo")
224
+ threading.Thread(target=_run_demo, args=(job,), daemon=True).start()
225
+ return JSONResponse({"job": job.id})
226
+
227
+ # -- 4. Your data -------------------------------------------------------
228
+ @app.get("/your-data", response_class=HTMLResponse)
229
+ def your_data(request: Request) -> HTMLResponse:
230
+ return page(request, "your_data.html.j2")
231
+
232
+ @app.get("/api/structure/{binder_id}")
233
+ def structure(binder_id: str) -> Any:
234
+ """The predicted complex for one binder, as mmCIF text.
235
+
236
+ Resolved by matching ``binder_id`` against the designs the benchmark
237
+ actually loaded, never by joining it onto a directory: a path built from
238
+ a request parameter turns a structure viewer into a file-read
239
+ primitive, and ``..`` is a legal path segment.
240
+ """
241
+ designer = showcase.load_designer_benchmark()
242
+ if designer is None:
243
+ return JSONResponse({"error": "no designer benchmark"}, status_code=404)
244
+ for binder in designer.with_structures():
245
+ if binder.binder_id == binder_id and binder.complex_cif is not None:
246
+ return Response(
247
+ binder.complex_cif.read_text(encoding="utf-8"),
248
+ media_type="chemical/x-cif",
249
+ )
250
+ return JSONResponse({"error": "no structure for that design"}, status_code=404)
251
+
252
+ # -- 5. Runs ------------------------------------------------------------
253
+ @app.get("/runs", response_class=HTMLResponse)
254
+ def runs(request: Request) -> HTMLResponse:
255
+ return page(request, "runs.html.j2", runs=_discover_runs(runs_root), root=runs_root)
256
+
257
+ # -- job status ---------------------------------------------------------
258
+ @app.get("/api/job/{job_id}")
259
+ def job_status(job_id: str) -> JSONResponse:
260
+ job = _get_job(job_id)
261
+ if job is None:
262
+ return JSONResponse({"error": "no such job"}, status_code=404)
263
+ return JSONResponse(
264
+ {
265
+ "id": job.id,
266
+ "state": job.state,
267
+ "stages": job.stages,
268
+ "error": job.error,
269
+ "run_dir": job.run_dir,
270
+ }
271
+ )
272
+
273
+ return app
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Chart specifications, built server-side so the page states its own numbers
278
+ # ---------------------------------------------------------------------------
279
+ def _paired_chart(designer: Any) -> dict[str, Any] | None:
280
+ """Designs against their own shuffles, one line per pair."""
281
+ if designer is None:
282
+ return None
283
+ root = None
284
+ try:
285
+ from bindsight.report.showcase import benchmarks_root
286
+
287
+ root = benchmarks_root()
288
+ except Exception: # pragma: no cover - defensive
289
+ return None
290
+ if root is None:
291
+ return None
292
+ path = root / "calibration" / "RESULTS.json"
293
+ if not path.is_file():
294
+ return None
295
+ try:
296
+ report = json.loads(path.read_text(encoding="utf-8"))
297
+ except (OSError, json.JSONDecodeError):
298
+ return None
299
+ pairs = report.get("pairs") or []
300
+ rows = [
301
+ {"id": p.get("binder_id", "?"), "a": float(p["design"]), "b": float(p["scramble"])}
302
+ for p in pairs
303
+ if p.get("design") is not None and p.get("scramble") is not None
304
+ ]
305
+ if not rows:
306
+ return None
307
+ return {
308
+ "pairs": rows,
309
+ "labelA": "design",
310
+ "labelB": "its own shuffle",
311
+ "threshold": report.get("threshold"),
312
+ "thresholdLabel": f"ipTM {report.get('threshold')}",
313
+ "title": "Each design beside a shuffle of its own sequence",
314
+ }
315
+
316
+
317
+ def _ci_label(confidence: Any) -> str:
318
+ """``95% CI`` when the artifact recorded a level, plain ``CI`` when it did not.
319
+
320
+ The same rule :func:`bindsight.report.format.interval` applies, in the form
321
+ the chart spec needs.
322
+ """
323
+ try:
324
+ return f"{float(confidence) * 100:g}% CI"
325
+ except (TypeError, ValueError):
326
+ return "CI"
327
+
328
+
329
+ def _nulls_chart(study: Any) -> dict[str, Any] | None:
330
+ """The two null models as intervals against the value that means 'nothing'."""
331
+ if study is None:
332
+ return None
333
+ gap = study.indication_gap or {}
334
+ if gap.get("point") is None:
335
+ return None
336
+ return {
337
+ "rows": [
338
+ {
339
+ "label": "indication gap",
340
+ "point": float(gap["point"]),
341
+ "low": float(gap["low"]),
342
+ "high": float(gap["high"]),
343
+ "n": gap.get("n_clusters"),
344
+ # Read, not assumed. Hardcoding the level here and again as
345
+ # the renderer's fallback would have captioned a study
346
+ # computed at another level as 95% from two places at once.
347
+ "ciLabel": _ci_label(gap.get("confidence")),
348
+ }
349
+ ],
350
+ "reference": 0.0,
351
+ "title": "Within-antigen difference, against zero",
352
+ }
353
+
354
+
355
+ def _recall_chart(study: Any) -> dict[str, Any] | None:
356
+ if study is None:
357
+ return None
358
+ cascade = (study.recall_cascade or {}).get("all", {}).get("at_k") or {}
359
+ rows = []
360
+ for key in sorted(cascade, key=lambda k: int(k.split("@")[-1])):
361
+ w = cascade[key].get("wilson") or {}
362
+ num, den = w.get("numerator"), w.get("denominator")
363
+ if num is None or den is None:
364
+ continue
365
+ # The level is recorded on every interval block in the study artifact.
366
+ # Dropping it printed a bare `CI` beside the number a reviewer reads
367
+ # first, and a 90% interval would have been indistinguishable from a 95%
368
+ # one.
369
+ ci = interval(w.get("low"), w.get("high"), digits=2, confidence=w.get("confidence"))
370
+ rows.append(
371
+ {
372
+ "label": key,
373
+ "value": num / den if den else 0,
374
+ "display": f"{num}/{den}",
375
+ "tip": f"{num} of {den} · {ci}",
376
+ }
377
+ )
378
+ if not rows:
379
+ return None
380
+ return {"rows": rows, "max": 1.0, "title": "Recall at each cutoff"}
381
+
382
+
383
+ # ---------------------------------------------------------------------------
384
+ # Runs and the demo
385
+ # ---------------------------------------------------------------------------
386
+ def load_run_manifest(run: Path) -> dict[str, Any] | None:
387
+ """A run's manifest as a dict, or ``None`` if there is not one to read.
388
+
389
+ ``None`` for absent, unreadable, or not-an-object. Each of those is a
390
+ different way of having no manifest, and none of them is an empty manifest:
391
+ returning ``{}`` would let a caller conclude the run recorded no failed
392
+ stages when in fact nothing was read.
393
+ """
394
+ path = run / "run_manifest.jsonld"
395
+ if not path.is_file():
396
+ return None
397
+ try:
398
+ body = json.loads(path.read_text(encoding="utf-8"))
399
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
400
+ return None
401
+ return body if isinstance(body, dict) else None
402
+
403
+
404
+ def stage_failures(manifest: dict[str, Any] | None) -> list[dict[str, str]]:
405
+ """Stages the manifest records as failed.
406
+
407
+ The distinction this exists for: *this cohort has no surface-antigen
408
+ candidates* is a finding, and *the differential-expression step crashed* is
409
+ a bug, and they leave behind the same empty tables. A run that failed must
410
+ never be presented in the layout that means "completed, and found nothing".
411
+ """
412
+ if not isinstance(manifest, dict):
413
+ return []
414
+ failures: list[dict[str, str]] = []
415
+ for stage in manifest.get("stages") or []:
416
+ if not isinstance(stage, dict):
417
+ continue
418
+ if str(stage.get("status", "")).lower() == "failed":
419
+ failures.append(
420
+ {
421
+ "name": str(stage.get("name", "?")),
422
+ "error": str(stage.get("error") or "no error recorded"),
423
+ }
424
+ )
425
+ return failures
426
+
427
+
428
+ # Open Targets reports this when a gene was mapped from a live record.
429
+ OT_STATUS_LIVE = "ok"
430
+
431
+
432
+ def open_targets_degradation(cand: Any) -> dict[str, Any] | None:
433
+ """How far the gene -> protein mapping fell back, or ``None`` if it did not.
434
+
435
+ Open Targets is the only genome-wide Ensembl -> UniProt source in the
436
+ pipeline. When it is unreachable (or disabled), the only mapping left is the
437
+ bundled table of a few well-known genes, and every gene missing from it is
438
+ dropped before the surfaceome filter. The resulting shortlist is drawn from
439
+ that fixed handful, not from the cohort, so it must not be read as a
440
+ genome-wide discovery -- and an absence from it means nothing at all.
441
+
442
+ Args:
443
+ cand: The candidates table, or ``None`` when the run produced none.
444
+
445
+ Returns:
446
+ ``None`` when every candidate was mapped live (the normal case, which
447
+ must stay silent) or when there is no status column to judge by.
448
+ Otherwise the counts, the per-status breakdown, and whether the outage
449
+ was total -- because a total outage means the shortlist is not a
450
+ discovery at all, and a partial one means it is merely incomplete.
451
+ """
452
+ if cand is None or "open_targets_status" not in getattr(cand, "columns", ()):
453
+ return None
454
+ statuses = [str(s) for s in cand["open_targets_status"]]
455
+ degraded = [s for s in statuses if s != OT_STATUS_LIVE]
456
+ if not degraded:
457
+ return None
458
+ return {
459
+ "degraded": len(degraded),
460
+ "total": len(statuses),
461
+ "breakdown": [{"status": s, "count": degraded.count(s)} for s in dict.fromkeys(degraded)],
462
+ # Nothing came back live, so the bundled table was the entire mapping.
463
+ "total_outage": len(degraded) == len(statuses),
464
+ }
465
+
466
+
467
+ def _read_candidates(run: Path) -> Any:
468
+ """A run's candidates table, or ``None`` when there is not one to read."""
469
+ path = run / "targets" / "candidates.parquet"
470
+ if not path.is_file():
471
+ return None
472
+ try:
473
+ import pandas as pd
474
+
475
+ return pd.read_parquet(path)
476
+ except Exception: # unreadable is not the same as absent, but both are None
477
+ return None
478
+
479
+
480
+ def _discover_runs(root: Path) -> list[dict[str, Any]]:
481
+ if not root.is_dir():
482
+ return []
483
+ found: list[dict[str, Any]] = []
484
+ for path in sorted(root.iterdir()):
485
+ if not path.is_dir():
486
+ continue
487
+ if path.name.startswith("_"):
488
+ # `runs/_design` is the GPU work cache, keyed by cache_key, not a
489
+ # run. Listing it as one told the reader two untrue things at once:
490
+ # that a run called `_design` exists, and that it is missing the
491
+ # manifest it was never supposed to have. The leading underscore is
492
+ # what marks these internal, and `submit_via_runner` is what creates
493
+ # them.
494
+ continue
495
+ manifest = path / "run_manifest.jsonld"
496
+ candidates = path / "targets" / "candidates.parquet"
497
+ body = load_run_manifest(path)
498
+ failures = stage_failures(body)
499
+ found.append(
500
+ {
501
+ "name": path.name,
502
+ "path": str(path),
503
+ "has_manifest": manifest.is_file(),
504
+ "has_candidates": candidates.is_file(),
505
+ "failures": failures,
506
+ # Three states, not two. "completed" and "found nothing" are the
507
+ # same layout; "crashed" must not be.
508
+ "verdict": ("failed" if failures else "unknown" if body is None else "completed"),
509
+ "open_targets": open_targets_degradation(_read_candidates(path)),
510
+ "mtime": path.stat().st_mtime,
511
+ }
512
+ )
513
+ return sorted(found, key=lambda r: r["mtime"], reverse=True)
514
+
515
+
516
+ def _demo_description() -> dict[str, str]:
517
+ return {
518
+ "cohort": "TCGA-BRCA, primary tumour against solid-tissue normal",
519
+ "source": "NIH/GDC, downloaded on first run and cached",
520
+ "hardware": "CPU only — no GPU, no account",
521
+ "duration": "a few minutes on a warm cache; longer on the first run, "
522
+ "which downloads the cohort",
523
+ }
524
+
525
+
526
+ _DEMO_STAGES = (
527
+ "fetch the cohort",
528
+ "differential expression",
529
+ "surface-exposure filter",
530
+ "target annotation",
531
+ "rank candidates",
532
+ "render the report",
533
+ )
534
+
535
+
536
+ def _run_demo(job: Job) -> None:
537
+ """Run the demo, recording which stage is active.
538
+
539
+ Each stage is named as it starts, so a reader can tell a slow run from a
540
+ stuck one — which a single indeterminate spinner cannot.
541
+ """
542
+ for name in _DEMO_STAGES:
543
+ job.step(name, "pending")
544
+ try:
545
+ from bindsight.pipelines import discover as discover_pipeline
546
+ from bindsight.report import render_run
547
+
548
+ cfg = _demo_config()
549
+ out_dir = Path.cwd() / "runs" / "demo"
550
+
551
+ job.step(_DEMO_STAGES[0], "running")
552
+ manifest = discover_pipeline.run(cfg, out_dir=out_dir)
553
+ job.finish_previous()
554
+ for name in _DEMO_STAGES[1:-1]:
555
+ job.step(name, "done")
556
+
557
+ job.step(_DEMO_STAGES[-1], "running")
558
+ render_run(out_dir)
559
+ job.finish_previous()
560
+
561
+ job.run_dir = str(out_dir)
562
+ job.state = "done"
563
+ LOG.info("demo complete: %s", manifest)
564
+ except FileNotFoundError as exc:
565
+ # Named separately: this is a packaging problem, and blaming the network
566
+ # for it told users to retry something that can never succeed.
567
+ job.state = "failed"
568
+ job.error = (
569
+ f"The bundled demo configuration is not available in this install "
570
+ f"({exc}). It ships with the repository rather than the wheel — "
571
+ "clone the repository to run the demo."
572
+ )
573
+ except Exception as exc: # surfaced to the page verbatim, not swallowed
574
+ job.state = "failed"
575
+ job.error = str(exc)
576
+ for stage in job.stages:
577
+ if stage["state"] == "running":
578
+ stage["state"] = "failed"
579
+
580
+
581
+ def _demo_config() -> Any:
582
+ """The bundled demo cohort's config.
583
+
584
+ ``RunConfig.from_yaml``, not a ``load_config`` helper: that name does not
585
+ exist, so the demo raised ImportError into the broad handler below and
586
+ reported it to the page as a failed run. mypy had it -- the module was
587
+ outside the type-check scope until it was widened.
588
+ """
589
+ from bindsight.config import RunConfig
590
+ from bindsight.report.showcase import benchmarks_root
591
+
592
+ root = benchmarks_root()
593
+ base = root.parent if root else Path.cwd()
594
+ path = base / "examples" / "demo" / "config.yaml"
595
+ if not path.is_file():
596
+ raise FileNotFoundError(str(path))
597
+ return RunConfig.from_yaml(path)
598
+
599
+
600
+ def serve(
601
+ *,
602
+ host: str = "127.0.0.1",
603
+ port: int = 8501,
604
+ open_browser: bool = True,
605
+ run_root: Path | None = None,
606
+ ) -> None:
607
+ """Run the interface.
608
+
609
+ Args:
610
+ host: interface to bind. Loopback by default; nothing is exposed to
611
+ the network unless the caller asks for it.
612
+ port: port to bind. An occupied port is a failed launch, and uvicorn
613
+ exits non-zero for it rather than reporting success.
614
+ open_browser: open a tab once the server is up.
615
+ run_root: where to look for runs. ``None`` means ``runs/`` under the
616
+ working directory -- the same default ``create_app`` documents.
617
+ """
618
+ import uvicorn
619
+
620
+ if open_browser:
621
+ import webbrowser
622
+
623
+ threading.Timer(1.0, lambda: webbrowser.open(f"http://{host}:{port}")).start()
624
+ uvicorn.run(create_app(run_root=run_root), host=host, port=port, log_level="warning")