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,595 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """RO-Crate exporter.
4
+
5
+ Bundles a finished bindsight run into a single ``.crate.zip`` that is
6
+ recognized by research data repositories, FAIR Digital Object Frameworks, and
7
+ the broader Research Object Crate ecosystem.
8
+
9
+ We emit the lightweight RO-Crate 1.1 metadata format
10
+ (https://www.researchobject.org/ro-crate/1.1/) — a single ``ro-crate-metadata.json``
11
+ at the crate root that catalogues every other file, plus a ``software.bib``
12
+ listing the upstream tools used. This is intentionally minimal: implementing
13
+ the full ``ro-crate-py`` library as a runtime dependency adds several
14
+ hundred KB of code for one JSON file.
15
+
16
+ Every payload file in the crate is byte-identical for runs with the same
17
+ manifest; the only varying field is ``datePublished`` in the metadata, which
18
+ records when the crate was packaged rather than anything about the run. So
19
+ this is a reproducibility primitive — depositing the crate in an archive that
20
+ mints identifiers gives one anybody can dereference to pull the exact artifacts
21
+ we packaged. Which archive is the depositor's choice; the crate names none.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import zipfile
29
+ from datetime import UTC, datetime
30
+ from pathlib import Path, PurePosixPath
31
+ from typing import Any
32
+
33
+ from bindsight import __version__
34
+
35
+ LOG = logging.getLogger(__name__)
36
+
37
+ RO_CRATE_CONTEXT = "https://w3id.org/ro/crate/1.1/context"
38
+
39
+
40
+ def export_ro_crate(
41
+ run_dir: Path | str,
42
+ out_path: Path | str | None = None,
43
+ ) -> Path:
44
+ """Bundle ``run_dir`` into an RO-Crate zip suitable for deposit.
45
+
46
+ Args:
47
+ run_dir: directory produced by ``bindsight discover`` (and optionally
48
+ ``design``, ``validate``, ``rank``, ``report``).
49
+ out_path: destination zip. Defaults to ``<run_dir>.crate.zip``.
50
+
51
+ Returns:
52
+ Path to the written zip.
53
+ """
54
+ run = Path(run_dir)
55
+ if not run.exists():
56
+ raise FileNotFoundError(f"run directory not found: {run}")
57
+ out = Path(out_path) if out_path else run.parent / f"{run.name}.crate.zip"
58
+
59
+ files = _collect_files(run)
60
+ external = _external_inputs(run)
61
+ metadata = _build_metadata(run, files, external)
62
+ bibtex = _build_software_bib(run)
63
+
64
+ out.parent.mkdir(parents=True, exist_ok=True)
65
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
66
+ # Required RO-Crate metadata file
67
+ zf.writestr("ro-crate-metadata.json", json.dumps(metadata, indent=2))
68
+ # Citation BibTeX
69
+ zf.writestr("software.bib", bibtex)
70
+ # Every artifact, preserving relative paths under the run root
71
+ for f in files:
72
+ arcname = f.relative_to(run).as_posix()
73
+ zf.write(f, arcname)
74
+ # The cohort the run consumed, which lives outside the run directory.
75
+ for arcname, src in external:
76
+ zf.write(src, arcname)
77
+
78
+ LOG.info(
79
+ "wrote RO-Crate: %s (%d run artifacts, %d carried-in inputs)",
80
+ out,
81
+ len(files),
82
+ len(external),
83
+ )
84
+ return out
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Helpers
89
+ # ---------------------------------------------------------------------------
90
+ #: Single files, in canonical order.
91
+ _PIPELINE_FILES = (
92
+ "config.yaml",
93
+ # The cohort itself. These are the only artifacts carrying TCGA case and
94
+ # sample barcodes, so without them an exported crate cannot answer the one
95
+ # question the provenance chain exists to answer: which patients did this
96
+ # binder come from? A crate that stops at the DEG table documents an
97
+ # analysis, not its origin.
98
+ "counts.tsv.gz",
99
+ "counts.tsv",
100
+ "design.tsv",
101
+ "provenance.json",
102
+ "deg/results.parquet",
103
+ "targets/candidates.parquet",
104
+ "epitopes/epitopes.parquet",
105
+ "taxonomy/failure_taxonomy.parquet",
106
+ "design/results.tar.gz",
107
+ "design/metrics.jsonl",
108
+ # Whether the ESM-2 pre-screen actually applied. It fails open, so a run
109
+ # whose embedding died carries every design under a cache key that says it
110
+ # kept the top k; this file is the only thing that distinguishes the two.
111
+ "design/prescreen.txt",
112
+ "validate/validated.parquet",
113
+ "rank/ranking.parquet",
114
+ "report.html",
115
+ "run_manifest.jsonld",
116
+ )
117
+
118
+ #: Directory trees included wholesale. ``structures/`` is what makes the crate
119
+ #: self-contained — candidate and epitope tables reference those files, and
120
+ #: without them every structure reference in an exported crate dangles.
121
+ #: ``design/`` and ``validate/`` subtrees carry the per-target archives and the
122
+ #: per-binder validator output that the flat allowlist silently dropped.
123
+ _PIPELINE_TREES = (
124
+ "structures",
125
+ "design/_targets",
126
+ "validate",
127
+ )
128
+
129
+ #: Skipped inside `_PIPELINE_TREES`; already listed individually above.
130
+ _TREE_EXCLUDE = frozenset({"validated.parquet"})
131
+
132
+
133
+ def _collect_files(run: Path) -> list[Path]:
134
+ """Return the per-run artifacts to include in the crate, in canonical order.
135
+
136
+ Zero-byte files are skipped: several stages write empty placeholders, and a
137
+ crate should describe what a run produced, not what it reserved space for.
138
+ """
139
+ files: list[Path] = []
140
+ seen: set[Path] = set()
141
+
142
+ def _add(p: Path) -> None:
143
+ if p in seen or not p.is_file():
144
+ return
145
+ try:
146
+ if p.stat().st_size <= 0:
147
+ return
148
+ except OSError: # pragma: no cover - race with a concurrent write
149
+ return
150
+ seen.add(p)
151
+ files.append(p)
152
+
153
+ for rel in _PIPELINE_FILES:
154
+ _add(run / rel)
155
+
156
+ for tree in _PIPELINE_TREES:
157
+ base = run / tree
158
+ if not base.is_dir():
159
+ continue
160
+ for p in sorted(base.rglob("*")):
161
+ if p.name not in _TREE_EXCLUDE:
162
+ _add(p)
163
+
164
+ return files
165
+
166
+
167
+ def _resolve_recorded(path_str: str, run: Path) -> Path | None:
168
+ """Resolve a manifest-recorded path to a file on this machine.
169
+
170
+ Recorded paths are relative to the working directory the run was launched
171
+ from, which is typically the repository root rather than the run directory,
172
+ and they are written in the launching platform's separator style. So try the
173
+ run directory first, then the working directory, then each ancestor. The run
174
+ comes first because a run-relative name like ``counts.tsv`` must not be
175
+ satisfied by an unrelated file of the same name sitting in the caller's
176
+ working directory.
177
+
178
+ Args:
179
+ path_str: the path as the manifest recorded it.
180
+ run: the run directory, used as the base for the upward search.
181
+
182
+ Returns:
183
+ The resolved file, or None if nothing matching exists.
184
+ """
185
+ raw = Path(_posix_key(path_str))
186
+ if raw.is_absolute():
187
+ return raw if raw.is_file() else None
188
+ for base in (run, Path.cwd(), *run.parents):
189
+ candidate = base / raw
190
+ if candidate.is_file():
191
+ return candidate
192
+ return None
193
+
194
+
195
+ def _external_inputs(run: Path) -> list[tuple[str, Path]]:
196
+ """Cohort inputs the run consumed from outside its own directory.
197
+
198
+ The allowlist above names ``counts.tsv.gz`` and ``design.tsv`` because they
199
+ are the only artifacts carrying TCGA case and sample barcodes — without them
200
+ a crate cannot answer which patients a binder came from. But naming them was
201
+ not enough: a run configured with ``inputs.counts`` pointing anywhere else
202
+ keeps them outside the run directory, so the allowlist matched nothing and
203
+ the crate shipped without a cohort. That was true of the provenance run this
204
+ was written for; the exported crate stopped at the DEG table.
205
+
206
+ The manifest already records every stage input by path and by sha256, so it
207
+ is the authoritative list. Anything it names that resolves outside the run
208
+ is carried in under ``inputs/``, keeping its basename so the recorded digest
209
+ still identifies it. A ``provenance.json`` sitting beside a carried-in input
210
+ comes too: it is the cohort's own record of how the sample set was
211
+ assembled, and no stage declares it as an input.
212
+
213
+ Args:
214
+ run: the run directory.
215
+
216
+ Returns:
217
+ ``(arcname, source)`` pairs, in manifest order, deduplicated.
218
+ """
219
+ manifest_path = run / "run_manifest.jsonld"
220
+ if not manifest_path.is_file():
221
+ return []
222
+ try:
223
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
224
+ except (OSError, json.JSONDecodeError) as e:
225
+ LOG.warning("could not read %s (%s); the crate will carry no inputs", manifest_path, e)
226
+ return []
227
+
228
+ run_resolved = run.resolve()
229
+ out: list[tuple[str, Path]] = []
230
+ taken: set[str] = set()
231
+ seen: set[Path] = set()
232
+ for stage in manifest.get("stages", []) or []:
233
+ for ref in stage.get("inputs") or []:
234
+ recorded = ref.get("path")
235
+ if not recorded:
236
+ continue
237
+ src = _resolve_recorded(str(recorded), run)
238
+ if src is None:
239
+ LOG.warning(
240
+ "input %s recorded by stage %s is not on this machine, so the "
241
+ "crate cannot carry it",
242
+ recorded,
243
+ stage.get("name", "?"),
244
+ )
245
+ continue
246
+ src = src.resolve()
247
+ if src in seen or src.is_relative_to(run_resolved):
248
+ continue # already inside the run, so the allowlist has it
249
+ seen.add(src)
250
+ arcname = f"inputs/{src.name}"
251
+ if arcname in taken: # two inputs sharing a basename
252
+ arcname = f"inputs/{stage.get('name', 'stage')}-{src.name}"
253
+ taken.add(arcname)
254
+ out.append((arcname, src))
255
+
256
+ # The cohort's own provenance document travels with the cohort. It records
257
+ # how the sample set was assembled — the GDC project, the workflow, the
258
+ # retrieval date, the cases requested against those actually obtained —
259
+ # which is what lets a reader rebuild the cohort rather than merely read it.
260
+ # It is not a stage input, so nothing in the manifest points at it.
261
+ for cohort_dir in dict.fromkeys(src.parent for _, src in list(out)):
262
+ doc = cohort_dir / "provenance.json"
263
+ if not doc.is_file() or doc.resolve() in seen:
264
+ continue
265
+ seen.add(doc.resolve())
266
+ arcname = "inputs/provenance.json"
267
+ if arcname in taken:
268
+ arcname = f"inputs/{cohort_dir.name}-provenance.json"
269
+ taken.add(arcname)
270
+ out.append((arcname, doc))
271
+ return out
272
+
273
+
274
+ def _manifest_digests(manifest: dict[str, Any]) -> dict[str, str]:
275
+ """Map run-relative artifact path -> sha256, as recorded in the manifest.
276
+
277
+ The manifest already carries a verified digest for every declared input and
278
+ output; the crate simply never carried them across, so a reader had no way
279
+ to check integrity. Paths are normalised to their run-relative form because
280
+ older manifests recorded absolute ones.
281
+ """
282
+ digests: dict[str, str] = {}
283
+ for stage in manifest.get("stages", []) or []:
284
+ for ref in [*(stage.get("inputs") or []), *(stage.get("outputs") or [])]:
285
+ path = ref.get("path")
286
+ sha = ref.get("sha256")
287
+ if path and sha:
288
+ # Keyed by the run-relative path, which is what the docstring
289
+ # above promises. Keying on the basename made two files called
290
+ # ``metrics.jsonl`` in different stages one entry, and left every
291
+ # file whose basename the manifest did not record undigested.
292
+ #
293
+ # Separators are normalised the same way ``_resolve_recorded``
294
+ # normalises them. ``Path(path).as_posix()`` is not enough: on
295
+ # POSIX a backslash is an ordinary filename character, so a
296
+ # manifest written on Windows -- which records
297
+ # ``runs\join\deg\results.parquet`` -- produced a single-segment
298
+ # key that no lookup in ``_digest_for`` could match. Every
299
+ # ``sha256`` silently vanished from crates exported on Linux and
300
+ # macOS, leaving the archive deposited as a run's integrity
301
+ # record with no integrity record in it. The two functions
302
+ # disagreeing about this is the whole defect.
303
+ digests[_posix_key(path)] = sha
304
+ return digests
305
+
306
+
307
+ def _posix_key(path_str: str) -> str:
308
+ """A recorded path as a POSIX key, whatever platform recorded it.
309
+
310
+ ``_resolve_recorded`` and ``_manifest_digests`` both consume manifest paths
311
+ and must agree on what a separator is. They did not: one replaced
312
+ backslashes, the other relied on ``Path.as_posix()``, which only converts
313
+ separators on the platform whose separator they are. Both now call this.
314
+ """
315
+ return PurePosixPath(path_str.replace("\\", "/")).as_posix()
316
+
317
+
318
+ def _digest_for(digests: dict[str, str], wanted: str) -> str | None:
319
+ """Find an artifact's recorded digest, precisely where possible.
320
+
321
+ Three steps, narrowing to broadening:
322
+
323
+ 1. The exact run-relative path, which is what the map is keyed by.
324
+ 2. A recorded path that ends with it — manifests written before paths were
325
+ normalised stored repository-relative ones such as
326
+ ``runs/join/deg/results.parquet``.
327
+ 3. The basename, but **only when exactly one recorded path has it**. This map
328
+ used to be keyed by basename outright, which silently merged two files
329
+ called ``metrics.jsonl`` into one entry and attached whichever digest won
330
+ to both. Requiring uniqueness keeps the convenience without the collision:
331
+ an ambiguous basename yields no digest rather than a wrong one.
332
+ """
333
+ exact = digests.get(wanted)
334
+ if exact is not None:
335
+ return exact
336
+
337
+ suffix_matches = [v for k, v in digests.items() if k.endswith("/" + wanted)]
338
+ if len(suffix_matches) == 1:
339
+ return suffix_matches[0]
340
+
341
+ base = PurePosixPath(wanted).name
342
+ by_base = [v for k, v in digests.items() if PurePosixPath(k).name == base]
343
+ if len(by_base) == 1:
344
+ return by_base[0]
345
+ return None
346
+
347
+
348
+ def _build_metadata(
349
+ run: Path, files: list[Path], external: list[tuple[str, Path]] | None = None
350
+ ) -> dict[str, Any]:
351
+ """Construct the RO-Crate 1.1 metadata document.
352
+
353
+ Args:
354
+ run: the run directory.
355
+ files: artifacts inside the run, named by their run-relative path.
356
+ external: ``(arcname, source)`` inputs carried in from outside it.
357
+ """
358
+ manifest_path = run / "run_manifest.jsonld"
359
+ manifest = {}
360
+ if manifest_path.exists():
361
+ try:
362
+ body = json.loads(manifest_path.read_text(encoding="utf-8"))
363
+ body.pop("@context", None)
364
+ manifest = body
365
+ except (json.JSONDecodeError, OSError) as e:
366
+ # `_external_inputs` logs this exact failure; this swallowed it with
367
+ # a bare `pass`. The crate then shipped with an empty run id and no
368
+ # sha256 on any file, and a consumer preparing a deposit could not
369
+ # tell that from a run which genuinely recorded no digests.
370
+ LOG.warning(
371
+ "could not read %s (%s); the crate will carry no run identity and no file digests",
372
+ manifest_path,
373
+ e,
374
+ )
375
+
376
+ digests = _manifest_digests(manifest)
377
+ file_entries: list[dict[str, object]] = []
378
+ for p in files:
379
+ entry: dict[str, object] = {
380
+ "@id": p.relative_to(run).as_posix(),
381
+ "@type": "File",
382
+ "name": p.name,
383
+ "contentSize": p.stat().st_size,
384
+ }
385
+ sha = _digest_for(digests, p.relative_to(run).as_posix())
386
+ if sha:
387
+ # schema.org has no sha256 term; this is the RO-Crate convention.
388
+ entry["sha256"] = sha
389
+ file_entries.append(entry)
390
+
391
+ for arcname, src in external or []:
392
+ input_entry: dict[str, object] = {
393
+ "@id": arcname,
394
+ "@type": "File",
395
+ "name": src.name,
396
+ "contentSize": src.stat().st_size,
397
+ "description": (
398
+ "Cohort input consumed by this run, carried into the crate from "
399
+ "outside the run directory so the provenance chain reaches the "
400
+ "patient barcodes it started from."
401
+ ),
402
+ }
403
+ # A carried-in input lives outside the run, so it has no run-relative
404
+ # path; the manifest recorded it however the run was launched. The
405
+ # resolver's unambiguous-basename fallback is what matches it.
406
+ sha = _digest_for(digests, src.name)
407
+ if sha:
408
+ input_entry["sha256"] = sha
409
+ file_entries.append(input_entry)
410
+
411
+ return {
412
+ "@context": RO_CRATE_CONTEXT,
413
+ "@graph": [
414
+ {
415
+ "@id": "ro-crate-metadata.json",
416
+ "@type": "CreativeWork",
417
+ "conformsTo": {"@id": "https://w3id.org/ro/crate/1.1"},
418
+ "about": {"@id": "./"},
419
+ },
420
+ {
421
+ "@id": "./",
422
+ "@type": "Dataset",
423
+ "name": manifest.get("name") or run.name,
424
+ "description": (
425
+ "bindsight run output: ranked de novo protein binder candidates "
426
+ "produced from RNA-seq counts, with full PROV-O provenance."
427
+ ),
428
+ "datePublished": datetime.now(UTC).strftime("%Y-%m-%d"),
429
+ "creator": {
430
+ "@type": "SoftwareApplication",
431
+ "name": "bindsight",
432
+ "version": __version__,
433
+ "url": "https://github.com/mikhaeelatefrizk/bindsight",
434
+ "license": "AGPL-3.0-or-later",
435
+ },
436
+ # software.bib is packaged and described below, so it belongs
437
+ # here too: a conforming RO-Crate reader walks hasPart, and a
438
+ # file absent from it is a file the crate does not declare.
439
+ "hasPart": [{"@id": e["@id"]} for e in file_entries] + [{"@id": "software.bib"}],
440
+ "bindsight:run_id": manifest.get("run_id", ""),
441
+ },
442
+ *file_entries,
443
+ {
444
+ "@id": "software.bib",
445
+ "@type": "File",
446
+ "name": "software.bib",
447
+ "encodingFormat": "application/x-bibtex",
448
+ "description": "BibTeX for every upstream tool this run depended on.",
449
+ },
450
+ ],
451
+ }
452
+
453
+
454
+ #: The upstream tools each bindsight wrapper actually runs, so the crate credits
455
+ #: them instead of the wrapper. Licences are the ones LICENSING.md records (its
456
+ #: header states the date the upstream LICENSE files were last re-verified);
457
+ #: repository URLs and pins come from :mod:`bindsight.runners.tools`, so a bumped
458
+ #: pin moves the citation with it rather than leaving it behind.
459
+ #:
460
+ #: This exists because the manifest records one ``ToolRef`` per stage and that
461
+ #: ref is the wrapper. Crediting BSD-3 RFdiffusion and MIT ProteinMPNN as
462
+ #: "bindsight.design.rfdiff_mpnn, AGPL-3.0-or-later" is wrong twice: it drops the
463
+ #: attribution those licences require, and it asserts terms over work that is not
464
+ #: bindsight's to license.
465
+ def _upstream_tools() -> dict[str, tuple[dict[str, str], ...]]:
466
+ """Wrapper tool name -> the upstream tools it invokes."""
467
+ from bindsight.runners import tools as T
468
+ from bindsight.validate.boltz2 import PINNED_BOLTZ2_VERSION
469
+
470
+ return {
471
+ "bindsight.design.rfdiff_mpnn": (
472
+ {
473
+ "name": "RFdiffusion",
474
+ "license": "BSD-3-Clause",
475
+ "url": T.RFDIFF_REPO,
476
+ "version": T.RFDIFF_COMMIT,
477
+ },
478
+ {
479
+ "name": "ProteinMPNN",
480
+ "license": "MIT",
481
+ "url": T.PROTEINMPNN_REPO,
482
+ "version": T.PROTEINMPNN_COMMIT,
483
+ },
484
+ ),
485
+ "bindsight.design.bindcraft": (
486
+ {
487
+ "name": "BindCraft",
488
+ "license": "MIT",
489
+ "url": T.BINDCRAFT_REPO,
490
+ "version": T.BINDCRAFT_COMMIT,
491
+ },
492
+ ),
493
+ "bindsight.design.boltzgen": (
494
+ {
495
+ "name": "BoltzGen",
496
+ "license": "MIT",
497
+ "url": T.BOLTZGEN_REPO,
498
+ "version": T.BOLTZGEN_COMMIT,
499
+ },
500
+ ),
501
+ "bindsight.validate.boltz2": (
502
+ {
503
+ "name": "Boltz-2",
504
+ "license": "MIT",
505
+ "url": "https://github.com/jwohlwend/boltz",
506
+ "version": PINNED_BOLTZ2_VERSION,
507
+ },
508
+ ),
509
+ "bindsight.validate.chai1r": (
510
+ {
511
+ "name": "Chai-1r",
512
+ "license": "Apache-2.0",
513
+ "url": "https://github.com/chaidiscovery/chai-lab",
514
+ "version": T.CHAI_PIP,
515
+ },
516
+ ),
517
+ "bindsight.validate.af2_ig": (
518
+ {
519
+ "name": "dl_binder_design (AF2 initial guess)",
520
+ "license": "PyRosetta non-commercial; AF2 parameters CC BY 4.0; code MIT",
521
+ "url": T.DL_BINDER_DESIGN_REPO,
522
+ "version": T.DL_BINDER_DESIGN_COMMIT,
523
+ },
524
+ ),
525
+ }
526
+
527
+
528
+ def _build_software_bib(run: Path) -> str:
529
+ """Produce a BibTeX block citing every upstream tool used in the run.
530
+
531
+ Reads tool entries from the manifest's ``stages[*].tool`` entries and
532
+ emits a ``@software`` BibTeX entry per unique (tool, version, license).
533
+ """
534
+ manifest_path = run / "run_manifest.jsonld"
535
+ if not manifest_path.exists():
536
+ return _DEFAULT_BIBTEX
537
+ try:
538
+ body = json.loads(manifest_path.read_text(encoding="utf-8"))
539
+ except json.JSONDecodeError:
540
+ return _DEFAULT_BIBTEX
541
+
542
+ seen: set[tuple[str, str]] = set()
543
+ entries: list[str] = [
544
+ "% Auto-generated by bindsight export. Cite the tools you actually used.",
545
+ "",
546
+ ]
547
+ upstream = _upstream_tools()
548
+ cited: list[dict[str, str]] = []
549
+ for stage in body.get("stages", []):
550
+ tool = stage.get("tool", {})
551
+ name = tool.get("name", "")
552
+ version = tool.get("version", "")
553
+ # The tools the wrapper ran, credited under their own names and licences.
554
+ for entry in upstream.get(name, ()):
555
+ if (entry["name"], entry["version"]) not in seen:
556
+ seen.add((entry["name"], entry["version"]))
557
+ cited.append(entry)
558
+ if not name or (name, version) in seen:
559
+ continue
560
+ seen.add((name, version))
561
+ key = name.replace("/", "_").replace(".", "_") + version.replace(".", "_")
562
+ url = tool.get("repo_url", "")
563
+ license_ = tool.get("license", "")
564
+ cite = tool.get("citation", "")
565
+ entries.append(
566
+ f"@software{{{key},\n"
567
+ f" title = {{{name}}},\n"
568
+ f" version = {{{version}}},\n"
569
+ f" license = {{{license_}}},\n"
570
+ f" url = {{{url}}},\n" + (f" doi = {{{cite}}},\n" if cite else "") + "}\n"
571
+ )
572
+ for entry in cited:
573
+ key = (
574
+ entry["name"].replace("/", "_").replace(".", "_").replace(" ", "_").replace("-", "_")
575
+ + "_"
576
+ + entry["version"].replace(".", "_")[:12]
577
+ )
578
+ entries.append(
579
+ f"@software{{{key},\n"
580
+ f" title = {{{entry['name']}}},\n"
581
+ f" version = {{{entry['version']}}},\n"
582
+ f" license = {{{entry['license']}}},\n"
583
+ f" url = {{{entry['url']}}},\n"
584
+ "}\n"
585
+ )
586
+ return "\n".join(entries) if len(entries) > 2 else _DEFAULT_BIBTEX
587
+
588
+
589
+ _DEFAULT_BIBTEX = """% bindsight run with no manifest entries detected.
590
+ @software{bindsight,
591
+ title = {bindsight: a reproducible bridge from RNA-seq to de novo protein binder design},
592
+ url = {https://github.com/mikhaeelatefrizk/bindsight},
593
+ license = {AGPL-3.0-or-later}
594
+ }
595
+ """
@@ -0,0 +1,19 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """I/O helpers — file readers, writers, cache directories."""
4
+
5
+ from bindsight.io.paths import (
6
+ adopt_structure,
7
+ cache_dir,
8
+ cache_root,
9
+ resolve_run_path,
10
+ run_dir,
11
+ )
12
+
13
+ __all__ = [
14
+ "adopt_structure",
15
+ "cache_dir",
16
+ "cache_root",
17
+ "resolve_run_path",
18
+ "run_dir",
19
+ ]