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.
- bindsight/README.md +44 -0
- bindsight/__init__.py +15 -0
- bindsight/benchmark/__init__.py +71 -0
- bindsight/benchmark/core.py +497 -0
- bindsight/benchmark/defaults.py +28 -0
- bindsight/benchmark/designer_bench.py +899 -0
- bindsight/benchmark/outcomes.py +508 -0
- bindsight/benchmark/panel.py +621 -0
- bindsight/benchmark/rediscovery.py +1153 -0
- bindsight/benchmark/statistics.py +622 -0
- bindsight/benchmark/study.py +1153 -0
- bindsight/benchmark/study_figures.py +164 -0
- bindsight/benchmark/study_report.py +607 -0
- bindsight/cli.py +2429 -0
- bindsight/config.py +451 -0
- bindsight/cost.py +258 -0
- bindsight/deg/__init__.py +7 -0
- bindsight/deg/pydeseq2_runner.py +285 -0
- bindsight/design/__init__.py +23 -0
- bindsight/design/_common.py +501 -0
- bindsight/design/bindcraft.py +78 -0
- bindsight/design/boltzgen.py +79 -0
- bindsight/design/developability.py +140 -0
- bindsight/design/embeddings.py +165 -0
- bindsight/design/prescreen.py +169 -0
- bindsight/design/protocol.py +111 -0
- bindsight/design/rfdiff_mpnn.py +93 -0
- bindsight/epitopes/__init__.py +7 -0
- bindsight/epitopes/surface_bind.py +134 -0
- bindsight/export/__init__.py +7 -0
- bindsight/export/ro_crate.py +595 -0
- bindsight/io/__init__.py +19 -0
- bindsight/io/cbioportal.py +118 -0
- bindsight/io/gdc.py +343 -0
- bindsight/io/paths.py +124 -0
- bindsight/pipelines/__init__.py +9 -0
- bindsight/pipelines/caveats.py +60 -0
- bindsight/pipelines/discover.py +1443 -0
- bindsight/pipelines/full_run.py +410 -0
- bindsight/plugins.py +335 -0
- bindsight/provenance/__init__.py +34 -0
- bindsight/provenance/append.py +199 -0
- bindsight/provenance/fragments.py +216 -0
- bindsight/provenance/manifest.py +506 -0
- bindsight/rank/__init__.py +7 -0
- bindsight/rank/scoring.py +355 -0
- bindsight/report/__init__.py +39 -0
- bindsight/report/format.py +100 -0
- bindsight/report/html.py +645 -0
- bindsight/report/showcase.py +653 -0
- bindsight/report/templates/report.css +67 -0
- bindsight/report/templates/report.html.j2 +420 -0
- bindsight/report/theme.py +231 -0
- bindsight/report/web/__init__.py +35 -0
- bindsight/report/web/app.py +624 -0
- bindsight/report/web/static/binder_viewer.js +245 -0
- bindsight/report/web/static/bindsight.css +455 -0
- bindsight/report/web/static/charts.js +425 -0
- bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
- bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
- bindsight/report/web/static/your_data_check.js +370 -0
- bindsight/report/web/templates/base.html.j2 +48 -0
- bindsight/report/web/templates/evidence.html.j2 +341 -0
- bindsight/report/web/templates/overview.html.j2 +140 -0
- bindsight/report/web/templates/runs.html.j2 +125 -0
- bindsight/report/web/templates/try.html.j2 +155 -0
- bindsight/report/web/templates/your_data.html.j2 +46 -0
- bindsight/runners/__init__.py +7 -0
- bindsight/runners/colab.py +158 -0
- bindsight/runners/job_exec.py +1110 -0
- bindsight/runners/kaggle.py +425 -0
- bindsight/runners/kaggle_kernel.py +489 -0
- bindsight/runners/local_docker.py +154 -0
- bindsight/runners/mock.py +167 -0
- bindsight/runners/modal_runner.py +199 -0
- bindsight/runners/notebook.py +116 -0
- bindsight/runners/notebook_content.py +217 -0
- bindsight/runners/protocol.py +81 -0
- bindsight/runners/source_wheel.py +104 -0
- bindsight/runners/tools.py +997 -0
- bindsight/structures/__init__.py +7 -0
- bindsight/structures/alphafolddb.py +93 -0
- bindsight/structures/plddt.py +92 -0
- bindsight/structures/topology.py +227 -0
- bindsight/surfaceome/__init__.py +42 -0
- bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
- bindsight/surfaceome/data/surfy_offline.txt +17 -0
- bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
- bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
- bindsight/surfaceome/surfy.py +441 -0
- bindsight/targets/__init__.py +7 -0
- bindsight/targets/ensembl_uniprot.py +56 -0
- bindsight/targets/gtex.py +250 -0
- bindsight/targets/open_targets.py +239 -0
- bindsight/validate/__init__.py +7 -0
- bindsight/validate/af2_ig.py +49 -0
- bindsight/validate/boltz2.py +256 -0
- bindsight/validate/chai1r.py +42 -0
- bindsight/validate/protocol.py +188 -0
- bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
- bindsight-0.3.5.dist-info/METADATA +495 -0
- bindsight-0.3.5.dist-info/RECORD +106 -0
- bindsight-0.3.5.dist-info/WHEEL +4 -0
- bindsight-0.3.5.dist-info/entry_points.txt +20 -0
- bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
- 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
|
+
)
|