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,506 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Pydantic v2 schema for ``run_manifest.jsonld`` — the bindsight provenance contract.
4
+
5
+ Every pipeline stage emits a :class:`StageRecord` and appends it to the
6
+ :class:`Manifest`. The manifest serializes as PROV-O JSON-LD so that downstream
7
+ RO-Crate packaging and external provenance tooling can consume it natively.
8
+
9
+ "PROV-O JSON-LD" is a claim about the *expanded* document, not just the file
10
+ extension: :meth:`Manifest.jsonld` types the run as a ``prov:Bundle``, each
11
+ stage as a ``prov:Activity``, each artifact as a content-addressed
12
+ ``prov:Entity`` and each tool/image as a ``prov:SoftwareAgent``, and
13
+ :data:`PROV_CONTEXT` binds every key to a term that actually exists — in PROV-O
14
+ where PROV-O defines one, under the ``bindsight:`` namespace otherwise. The
15
+ key names themselves are unchanged, so the file still reads as plain JSON.
16
+
17
+ The shape and semantics here are stable; bump :data:`MANIFEST_SCHEMA_VERSION`
18
+ when changing them.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import importlib.metadata
25
+ import json
26
+ import platform
27
+ import sys
28
+ import uuid
29
+ from collections.abc import Iterable
30
+ from datetime import UTC, datetime
31
+ from pathlib import Path
32
+ from typing import Any, Literal
33
+ from urllib.parse import quote
34
+
35
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
36
+
37
+ # Bumped when the schema changes in a backwards-incompatible way. 1.1.0 added
38
+ # RuntimeRef.libraries and the "skipped" stage status: manifests written at
39
+ # 1.1.0 do not validate against a 1.0.0 reader (the models forbid extras).
40
+ MANIFEST_SCHEMA_VERSION = "1.1.0"
41
+
42
+ #: Namespace for bindsight's own terms and node identifiers. Everything PROV-O
43
+ #: and schema.org do not define lives here, rather than being minted by an
44
+ #: ``@vocab`` default into IRIs that resolve to nothing.
45
+ BINDSIGHT_NS = "https://github.com/mikhaeelatefrizk/bindsight/ns#"
46
+
47
+ # JSON-LD 1.1 context for the manifest. Every key is bound explicitly and there
48
+ # is no ``@vocab``, so a PROV-aware consumer sees real PROV-O predicates
49
+ # (prov:startedAtTime, prov:used, prov:generated, prov:wasAssociatedWith …) and
50
+ # an undeclared key is dropped on expansion instead of being invented.
51
+ PROV_CONTEXT: dict[str, Any] = {
52
+ # Required for the @json literals and the property-scoped context below.
53
+ "@version": 1.1,
54
+ "prov": "http://www.w3.org/ns/prov#",
55
+ "schema": "http://schema.org/",
56
+ "xsd": "http://www.w3.org/2001/XMLSchema#",
57
+ "bindsight": BINDSIGHT_NS,
58
+ # ---- run (prov:Bundle) ----
59
+ "schema_version": "bindsight:schemaVersion",
60
+ "run_id": "bindsight:runId",
61
+ "created_at": {"@id": "prov:generatedAtTime", "@type": "xsd:dateTime"},
62
+ "name": "schema:name",
63
+ "config_path": "bindsight:configPath",
64
+ "runtime": "bindsight:runtimeEnvironment",
65
+ # PROV-O has no predicate from a bundle to the activities it describes
66
+ # (prov:hadMember ranges over entities), so this one is ours; the stage
67
+ # nodes themselves carry the PROV links a consumer traverses.
68
+ "stages": "bindsight:stage",
69
+ # ---- runtime environment ----
70
+ "python": "bindsight:pythonVersion",
71
+ "platform": "bindsight:platform",
72
+ "machine": "bindsight:machine",
73
+ "bindsight_version": "bindsight:bindsightVersion",
74
+ "libraries": {"@id": "bindsight:libraryVersions", "@type": "@json"},
75
+ # ---- stage (prov:Activity) ----
76
+ "started_at": {"@id": "prov:startedAtTime", "@type": "xsd:dateTime"},
77
+ "ended_at": {"@id": "prov:endedAtTime", "@type": "xsd:dateTime"},
78
+ "status": "bindsight:status",
79
+ "tool": "prov:wasAssociatedWith",
80
+ # Inside a container reference, ``runtime`` names the container engine, not
81
+ # the run's environment — rebound in this property's scope (JSON-LD 1.1).
82
+ "container": {
83
+ "@id": "prov:wasAssociatedWith",
84
+ "@context": {"runtime": "bindsight:containerRuntime"},
85
+ },
86
+ "inputs": "prov:used",
87
+ "outputs": "prov:generated",
88
+ # Stage parameters are free-form, so they are carried as a JSON literal
89
+ # rather than expanded key by key.
90
+ "params": {"@id": "bindsight:params", "@type": "@json"},
91
+ "cache_key": "bindsight:cacheKey",
92
+ "cache_status": "bindsight:cacheStatus",
93
+ "notes": "schema:description",
94
+ "error": "bindsight:error",
95
+ # ---- tool / container image (prov:SoftwareAgent) ----
96
+ "version": "schema:softwareVersion",
97
+ "license": "schema:license",
98
+ "repo_url": {"@id": "schema:codeRepository", "@type": "@id"},
99
+ "commit_sha": "bindsight:commitSha",
100
+ "weights_sha256": "bindsight:weightsSha256",
101
+ "citation": "schema:citation",
102
+ "image": "bindsight:containerImage",
103
+ "tag": "bindsight:containerTag",
104
+ "digest": "bindsight:containerDigest",
105
+ # ---- artifacts (prov:Entity) ----
106
+ "role": "bindsight:role",
107
+ "path": "bindsight:path",
108
+ "sha256": "bindsight:sha256",
109
+ "bytes": {"@id": "schema:contentSize", "@type": "xsd:nonNegativeInteger"},
110
+ "media_type": "schema:encodingFormat",
111
+ }
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Helpers
116
+ # ---------------------------------------------------------------------------
117
+ def sha256_file(path: Path | str, *, chunk_size: int = 1 << 20) -> str:
118
+ """Compute the lowercase hex SHA-256 of a file.
119
+
120
+ Streams the file in ``chunk_size`` byte chunks to bound memory use on
121
+ large structure or counts files.
122
+ """
123
+ digest = hashlib.sha256()
124
+ p = Path(path)
125
+ with p.open("rb") as fh:
126
+ while chunk := fh.read(chunk_size):
127
+ digest.update(chunk)
128
+ return digest.hexdigest()
129
+
130
+
131
+ def _now_iso() -> str:
132
+ """ISO-8601 UTC timestamp suitable for JSON-LD ``xsd:dateTime``."""
133
+ return datetime.now(UTC).isoformat(timespec="seconds")
134
+
135
+
136
+ def _slug(value: str) -> str:
137
+ """Percent-encode ``value`` so it is safe inside one IRI segment."""
138
+ return quote(value, safe="")
139
+
140
+
141
+ def _entity_node(ref: dict[str, Any]) -> dict[str, Any]:
142
+ """Type one input/output reference as a ``prov:Entity``.
143
+
144
+ Identity is the content digest, so an artifact generated by one stage and
145
+ consumed by the next is a single node in the graph rather than two
146
+ unrelated ones.
147
+ """
148
+ return {"@id": f"urn:sha256:{ref['sha256']}", "@type": "prov:Entity", **ref}
149
+
150
+
151
+ def _agent_node(tool: dict[str, Any]) -> dict[str, Any]:
152
+ """Type a tool reference as a ``prov:SoftwareAgent``, identified by name+version."""
153
+ name = _slug(str(tool["name"]))
154
+ version = _slug(str(tool["version"]))
155
+ return {"@id": f"{BINDSIGHT_NS}tool/{name}/{version}", "@type": "prov:SoftwareAgent", **tool}
156
+
157
+
158
+ def _container_node(container: dict[str, Any]) -> dict[str, Any]:
159
+ """Type a container image as a ``prov:SoftwareAgent``, identified by its digest."""
160
+ image = _slug(str(container["image"]))
161
+ digest = _slug(str(container["digest"]))
162
+ return {
163
+ "@id": f"{BINDSIGHT_NS}container/{image}/{digest}",
164
+ "@type": "prov:SoftwareAgent",
165
+ **container,
166
+ }
167
+
168
+
169
+ def _activity_node(stage: dict[str, Any], *, run_iri: str, index: int) -> dict[str, Any]:
170
+ """Type one serialized stage as a ``prov:Activity`` with typed sub-nodes."""
171
+ node: dict[str, Any] = {
172
+ "@id": f"{run_iri}#stage/{index}/{_slug(str(stage['name']))}",
173
+ "@type": "prov:Activity",
174
+ **stage,
175
+ }
176
+ node["tool"] = _agent_node(stage["tool"])
177
+ if stage.get("container") is not None:
178
+ node["container"] = _container_node(stage["container"])
179
+ node["inputs"] = [_entity_node(r) for r in stage.get("inputs") or []]
180
+ node["outputs"] = [_entity_node(r) for r in stage.get("outputs") or []]
181
+ return node
182
+
183
+
184
+ def _strip_jsonld(value: Any) -> Any:
185
+ """Recursively drop JSON-LD keywords (``@context``, ``@id``, ``@type``).
186
+
187
+ The models forbid extra fields, so the framing added at serialization time
188
+ has to come back off before validation. ``params`` is stage-supplied data
189
+ rather than provenance framing, so it is passed through untouched.
190
+ """
191
+ if isinstance(value, dict):
192
+ return {
193
+ k: (v if k == "params" else _strip_jsonld(v))
194
+ for k, v in value.items()
195
+ if not k.startswith("@")
196
+ }
197
+ if isinstance(value, list):
198
+ return [_strip_jsonld(v) for v in value]
199
+ return value
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Component models
204
+ # ---------------------------------------------------------------------------
205
+ class ToolRef(BaseModel):
206
+ """A pinned reference to an external tool a stage depends on."""
207
+
208
+ model_config = ConfigDict(extra="forbid", frozen=True)
209
+
210
+ name: str = Field(..., description="Canonical tool name (e.g. 'pydeseq2', 'rfdiffusion').")
211
+ version: str = Field(..., description="Tool version string as reported by the tool itself.")
212
+ license: str = Field(..., description="SPDX identifier or short license name.")
213
+ repo_url: str | None = Field(None, description="Upstream source repository URL.")
214
+ commit_sha: str | None = Field(
215
+ None, description="Pinned commit SHA when relying on a specific tree."
216
+ )
217
+ weights_sha256: str | None = Field(
218
+ None,
219
+ description="SHA-256 of the model weights file, when the caller knows it. "
220
+ "Nothing in this repository sets it yet: the design backends pass None "
221
+ "because the checkpoints are multi-GB and verified at download instead, so "
222
+ "a manifest pins the tool and container but not the weights inside them.",
223
+ )
224
+ citation: str | None = Field(
225
+ None, description="DOI or BibTeX key. Aggregated into ``software.bib`` at export time."
226
+ )
227
+
228
+
229
+ class ContainerRef(BaseModel):
230
+ """A pinned container image used to execute a stage."""
231
+
232
+ model_config = ConfigDict(extra="forbid", frozen=True)
233
+
234
+ image: str = Field(..., description="Image name (e.g. 'ghcr.io/mikhaeelatefrizk/bindsight').")
235
+ tag: str | None = Field(None, description="Tag at submission time (informational only).")
236
+ digest: str = Field(
237
+ ...,
238
+ description="Immutable digest, e.g. 'sha256:abc123…'. Required — tags are mutable.",
239
+ pattern=r"^sha256:[0-9a-f]{64}$",
240
+ )
241
+ runtime: Literal["docker", "apptainer", "podman", "none"] = "docker"
242
+
243
+
244
+ class InputRef(BaseModel):
245
+ """A pinned reference to a single input artifact consumed by a stage."""
246
+
247
+ model_config = ConfigDict(extra="forbid", frozen=True)
248
+
249
+ role: str = Field(..., description="Logical role within the stage (e.g. 'counts', 'design').")
250
+ path: str = Field(..., description="Path relative to the run root.")
251
+ sha256: str = Field(..., min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$")
252
+ bytes: int = Field(..., ge=0)
253
+ media_type: str | None = Field(
254
+ None,
255
+ description="IANA media type (e.g. 'text/tab-separated-values', "
256
+ "'application/x-parquet', 'chemical/x-mmcif').",
257
+ )
258
+
259
+
260
+ class OutputRef(BaseModel):
261
+ """A pinned reference to a single output artifact produced by a stage."""
262
+
263
+ model_config = ConfigDict(extra="forbid", frozen=True)
264
+
265
+ role: str = Field(..., description="Logical role within the stage (e.g. 'targets').")
266
+ path: str = Field(..., description="Path relative to the run root.")
267
+ sha256: str = Field(..., min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$")
268
+ bytes: int = Field(..., ge=0)
269
+ media_type: str | None = None
270
+
271
+
272
+ class StageRecord(BaseModel):
273
+ """One stage of the pipeline.
274
+
275
+ Stages are appended to :attr:`Manifest.stages` in execution order. A stage
276
+ with the same ``cache_key`` as an earlier stage (across runs) MAY be
277
+ skipped if its outputs already exist on disk and validate against the
278
+ recorded sha256.
279
+ """
280
+
281
+ model_config = ConfigDict(extra="forbid")
282
+
283
+ name: str = Field(..., description="Stage name (e.g. 'deg', 'discover', 'design').")
284
+ started_at: str = Field(default_factory=_now_iso)
285
+ ended_at: str | None = None
286
+ # "skipped" = the stage was not attempted (opt-out flag, or an upstream
287
+ # stage produced nothing to work on); distinct from "skipped_cache", which
288
+ # means its outputs already existed and validated.
289
+ status: Literal["running", "completed", "failed", "skipped", "skipped_cache"] = "running"
290
+
291
+ tool: ToolRef
292
+ container: ContainerRef | None = None
293
+ inputs: list[InputRef] = Field(default_factory=list)
294
+ outputs: list[OutputRef] = Field(default_factory=list)
295
+ params: dict[str, Any] = Field(default_factory=dict)
296
+
297
+ cache_key: str | None = Field(
298
+ None,
299
+ description="SHA-256 over (input shas + tool + container + params) for idempotent reruns.",
300
+ )
301
+ cache_status: Literal["hit", "miss"] | None = Field(
302
+ None,
303
+ description=(
304
+ "Whether the stage reused a cached result ('hit') or computed a new one "
305
+ "('miss'). None when the stage has no cache. A reader can tell reused work "
306
+ "from repeated work without rerunning anything."
307
+ ),
308
+ )
309
+ notes: str | None = None
310
+ error: str | None = Field(None, description="Stack trace or error string if status='failed'.")
311
+
312
+ @field_validator("status")
313
+ @classmethod
314
+ def _check_terminal_status(cls, v: str) -> str:
315
+ # Allow all defined statuses; this validator is a hook for future rules.
316
+ return v
317
+
318
+ def mark_completed(
319
+ self,
320
+ *,
321
+ outputs: Iterable[OutputRef] = (),
322
+ ) -> None:
323
+ """Mark this stage as completed and stamp the end time."""
324
+ self.outputs.extend(outputs)
325
+ self.status = "completed"
326
+ self.ended_at = _now_iso()
327
+
328
+ def mark_failed(self, error: str) -> None:
329
+ """Mark this stage as failed with an error message."""
330
+ self.status = "failed"
331
+ self.ended_at = _now_iso()
332
+ self.error = error
333
+
334
+ def mark_skipped(self, reason: str) -> None:
335
+ """Mark this stage as not attempted, recording why in ``notes``."""
336
+ self.status = "skipped"
337
+ self.ended_at = _now_iso()
338
+ self.notes = reason
339
+
340
+
341
+ class RuntimeRef(BaseModel):
342
+ """Capture of the local runtime environment.
343
+
344
+ Recorded once per run: the Python version, the platform, and the resolved
345
+ version of every library whose release can move the numbers a run reports
346
+ (see :data:`SCIENTIFIC_STACK`). Reproducibility requires matching the
347
+ container digest, not just this — but this is useful triage when a
348
+ container can't be rebuilt.
349
+ """
350
+
351
+ model_config = ConfigDict(extra="forbid", frozen=True)
352
+
353
+ python: str
354
+ platform: str
355
+ machine: str
356
+ bindsight_version: str
357
+ libraries: dict[str, str | None] = Field(
358
+ default_factory=dict,
359
+ description="Resolved version of each tracked scientific dependency. "
360
+ "``null`` means the package is not installed in this environment; an "
361
+ "empty mapping means the manifest predates library capture.",
362
+ )
363
+
364
+
365
+ class Manifest(BaseModel):
366
+ """Top-level pipeline manifest. Serializes to ``run_manifest.jsonld``."""
367
+
368
+ model_config = ConfigDict(extra="forbid")
369
+
370
+ schema_version: str = MANIFEST_SCHEMA_VERSION
371
+ run_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
372
+ created_at: str = Field(default_factory=_now_iso)
373
+ name: str | None = Field(None, description="Optional human label for the run.")
374
+ config_path: str | None = Field(
375
+ None, description="Path to the pipeline config YAML used to launch this run."
376
+ )
377
+
378
+ runtime: RuntimeRef
379
+ stages: list[StageRecord] = Field(default_factory=list)
380
+
381
+ def jsonld(self) -> dict[str, Any]:
382
+ """Return the manifest as PROV-O JSON-LD.
383
+
384
+ Node identity and typing are applied here rather than stored on the
385
+ models: the run is a ``prov:Bundle`` identified by its run UUID, each
386
+ stage a ``prov:Activity``, each artifact a content-addressed
387
+ ``prov:Entity``, and each tool or container image a
388
+ ``prov:SoftwareAgent``. Key names are untouched — :data:`PROV_CONTEXT`
389
+ maps them onto PROV-O and schema.org — so the document expands to real
390
+ provenance triples while staying readable as plain JSON.
391
+ """
392
+ body = self.model_dump(mode="json")
393
+ run_iri = f"urn:uuid:{self.run_id}"
394
+ body["runtime"] = {
395
+ "@id": f"{run_iri}#runtime",
396
+ "@type": ["prov:Entity", "bindsight:RuntimeEnvironment"],
397
+ **body["runtime"],
398
+ }
399
+ body["stages"] = [
400
+ _activity_node(s, run_iri=run_iri, index=i)
401
+ for i, s in enumerate(body.get("stages") or [])
402
+ ]
403
+ return {"@context": PROV_CONTEXT, "@id": run_iri, "@type": "prov:Bundle", **body}
404
+
405
+ def write(self, path: Path | str) -> Path:
406
+ """Atomically write the manifest as JSON-LD to ``path``.
407
+
408
+ Writes to ``path + '.tmp'`` first, then replaces, so an interrupted
409
+ write never leaves a half-written manifest behind.
410
+ """
411
+ p = Path(path)
412
+ tmp = p.with_suffix(p.suffix + ".tmp")
413
+ tmp.write_text(
414
+ json.dumps(self.jsonld(), indent=2, sort_keys=False), encoding="utf-8", newline="\n"
415
+ )
416
+ tmp.replace(p)
417
+ return p
418
+
419
+ @classmethod
420
+ def read(cls, path: Path | str) -> Manifest:
421
+ """Load a manifest from disk, stripping the JSON-LD framing."""
422
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
423
+ return cls.model_validate(_strip_jsonld(raw))
424
+
425
+ def append(self, stage: StageRecord) -> None:
426
+ """Append a stage record to this manifest."""
427
+ self.stages.append(stage)
428
+
429
+
430
+ # ---------------------------------------------------------------------------
431
+ # Constructors
432
+ # ---------------------------------------------------------------------------
433
+ #: Distributions whose release can change the numbers a run reports — the DEG
434
+ #: statistics, the parquet encoding, the structure parsing, the ESM-2 prescreen.
435
+ #: pyproject.toml bounds them and ``envs/constraints.txt`` pins them; recording
436
+ #: what actually resolved is what lets a published number be re-derived later.
437
+ SCIENTIFIC_STACK: tuple[str, ...] = (
438
+ "numpy",
439
+ "scipy",
440
+ "pandas",
441
+ "pyarrow",
442
+ "pydeseq2",
443
+ # pydeseq2's design-matrix engine: it turns ``~ case_barcode + condition``
444
+ # into the matrix the fit runs on, so its release can move a log2 fold change
445
+ # without anything else changing. It was pinned in envs/constraints.txt and
446
+ # absent from this list, which is the combination that makes a pin useless --
447
+ # constrained at install time, unrecorded in the artifact.
448
+ "formulaic",
449
+ "formulaic-contrasts",
450
+ "biopython",
451
+ "scikit-learn",
452
+ "anndata",
453
+ # anndata's on-disk backends. A release that changes how a matrix round-trips
454
+ # changes the numbers read back out of it.
455
+ "h5py",
456
+ "zarr",
457
+ "numcodecs",
458
+ "torch",
459
+ "transformers",
460
+ )
461
+
462
+ #: Pinned distributions that cannot change a reported number: they render, they
463
+ #: do not compute. Listed explicitly rather than left out, so that every pin is
464
+ #: accounted for and a new one has to be classified rather than silently omitted.
465
+ PRESENTATION_ONLY: tuple[str, ...] = ("matplotlib",)
466
+
467
+
468
+ def _capture_libraries() -> dict[str, str | None]:
469
+ """Resolve the installed version of every distribution in :data:`SCIENTIFIC_STACK`.
470
+
471
+ A distribution that is not installed maps to ``None`` rather than being
472
+ omitted or given a placeholder, so a reader can tell "absent from this
473
+ environment" from "never recorded".
474
+ """
475
+ versions: dict[str, str | None] = {}
476
+ for dist in SCIENTIFIC_STACK:
477
+ try:
478
+ versions[dist] = importlib.metadata.version(dist)
479
+ except importlib.metadata.PackageNotFoundError:
480
+ versions[dist] = None
481
+ return versions
482
+
483
+
484
+ def _capture_runtime() -> RuntimeRef:
485
+ from bindsight import __version__ as xpr_version
486
+
487
+ return RuntimeRef(
488
+ python=sys.version.split()[0],
489
+ platform=platform.platform(),
490
+ machine=platform.machine(),
491
+ bindsight_version=xpr_version,
492
+ libraries=_capture_libraries(),
493
+ )
494
+
495
+
496
+ def new_manifest(
497
+ *,
498
+ name: str | None = None,
499
+ config_path: str | Path | None = None,
500
+ ) -> Manifest:
501
+ """Create a fresh :class:`Manifest` with the local runtime captured."""
502
+ return Manifest(
503
+ name=name,
504
+ config_path=str(config_path) if config_path is not None else None,
505
+ runtime=_capture_runtime(),
506
+ )
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Multi-objective ranking of validated binders."""
4
+
5
+ from bindsight.rank.scoring import rank_run, rank_validated
6
+
7
+ __all__ = ["rank_run", "rank_validated"]