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
bindsight/plugins.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Plugin loader — resolve designers / validators / runners by name.
|
|
4
|
+
|
|
5
|
+
Looks up the entry points declared in ``pyproject.toml``
|
|
6
|
+
(``bindsight.designers`` / ``bindsight.validators`` / ``bindsight.runners``) so
|
|
7
|
+
third parties can register their own without forking. Falls back to the bundled
|
|
8
|
+
import paths if the package metadata isn't available (editable corner cases).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from importlib.metadata import entry_points
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
LOG = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_FALLBACK = {
|
|
21
|
+
"bindsight.designers": {
|
|
22
|
+
"rfdiff_mpnn": "bindsight.design.rfdiff_mpnn:RFdiffMPNNDesigner",
|
|
23
|
+
"bindcraft": "bindsight.design.bindcraft:BindCraftDesigner",
|
|
24
|
+
"boltzgen": "bindsight.design.boltzgen:BoltzGenDesigner",
|
|
25
|
+
},
|
|
26
|
+
"bindsight.validators": {
|
|
27
|
+
"boltz2": "bindsight.validate.boltz2:Boltz2Validator",
|
|
28
|
+
"chai1r": "bindsight.validate.chai1r:Chai1rValidator",
|
|
29
|
+
"af2_ig": "bindsight.validate.af2_ig:AF2IGValidator",
|
|
30
|
+
},
|
|
31
|
+
"bindsight.runners": {
|
|
32
|
+
"colab": "bindsight.runners.colab:ColabRunner",
|
|
33
|
+
"modal": "bindsight.runners.modal_runner:ModalRunner",
|
|
34
|
+
"kaggle": "bindsight.runners.kaggle:KaggleRunner",
|
|
35
|
+
"local_docker": "bindsight.runners.local_docker:LocalDockerRunner",
|
|
36
|
+
"mock": "bindsight.runners.mock:MockRunner",
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def registered(group: str) -> frozenset[str]:
|
|
42
|
+
"""Every plugin name in ``group``: entry points, plus the bundled fallbacks.
|
|
43
|
+
|
|
44
|
+
These constants were built from ``_FALLBACK`` alone, so the registry's own
|
|
45
|
+
idea of "everything we know about" omitted the entry points the loader
|
|
46
|
+
resolves -- and the config and CLI, which enumerate the same names again by
|
|
47
|
+
hand, rejected a registered plugin before :func:`_load` was ever called. The
|
|
48
|
+
module exists to let third parties register a designer without forking; this
|
|
49
|
+
is what makes that reachable.
|
|
50
|
+
|
|
51
|
+
The bundled names are unioned in rather than used only as a fallback: an
|
|
52
|
+
editable install without resolvable metadata must still offer the three
|
|
53
|
+
designers that ship with the package.
|
|
54
|
+
"""
|
|
55
|
+
names = set(_FALLBACK.get(group, {}))
|
|
56
|
+
try:
|
|
57
|
+
names.update(ep.name for ep in entry_points(group=group))
|
|
58
|
+
except Exception as exc: # pragma: no cover - metadata edge cases
|
|
59
|
+
LOG.warning("could not read the %s entry-point group (%s)", group, exc)
|
|
60
|
+
return frozenset(names)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def all_designers() -> frozenset[str]:
|
|
64
|
+
"""Designer names that can be selected. Resolved on each call.
|
|
65
|
+
|
|
66
|
+
A function rather than a constant because entry points are resolved from
|
|
67
|
+
installed metadata, and a module-level frozenset would freeze whatever was
|
|
68
|
+
installed when :mod:`bindsight.plugins` was first imported.
|
|
69
|
+
"""
|
|
70
|
+
return registered("bindsight.designers")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def all_validators() -> frozenset[str]:
|
|
74
|
+
"""Validator names that can be selected. Resolved on each call."""
|
|
75
|
+
return registered("bindsight.validators")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def all_runners() -> frozenset[str]:
|
|
79
|
+
"""Runner backend names that can be selected. Resolved on each call."""
|
|
80
|
+
return registered("bindsight.runners")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
#: The bundled names, for the places that mean *these three* rather than
|
|
84
|
+
#: *whatever is installed* -- a default set for a benchmark, say.
|
|
85
|
+
BUNDLED_DESIGNERS: frozenset[str] = frozenset(_FALLBACK["bindsight.designers"])
|
|
86
|
+
BUNDLED_VALIDATORS: frozenset[str] = frozenset(_FALLBACK["bindsight.validators"])
|
|
87
|
+
BUNDLED_RUNNERS: frozenset[str] = frozenset(_FALLBACK["bindsight.runners"])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
SUPPORTED = "supported"
|
|
91
|
+
UNTESTED = "untested"
|
|
92
|
+
UNSUPPORTED = "unsupported"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class BackendCapability:
|
|
97
|
+
"""What one backend's environment can actually run.
|
|
98
|
+
|
|
99
|
+
The distinction that matters is between *unsupported* and *untested*, and
|
|
100
|
+
collapsing them would be its own kind of dishonesty.
|
|
101
|
+
|
|
102
|
+
``provides`` is what the backend builds an environment for. ``attempts`` is
|
|
103
|
+
what :mod:`bindsight.runners.job_exec` would try to bootstrap at run time —
|
|
104
|
+
it clones RFdiffusion, fetches and verifies its weights and pip-installs the
|
|
105
|
+
SE3 stack, so a general-purpose CUDA image may well succeed without the
|
|
106
|
+
backend having prepared anything. Nothing outside ``provides`` has been
|
|
107
|
+
demonstrated, so an attempt is allowed and announced rather than blocked.
|
|
108
|
+
|
|
109
|
+
Anything in neither set cannot run there at all, and asking for it should
|
|
110
|
+
cost a second locally instead of six minutes of a weekly GPU quota.
|
|
111
|
+
|
|
112
|
+
Attributes:
|
|
113
|
+
provides: plugins this backend builds an environment for.
|
|
114
|
+
attempts: plugins the executor would try to bootstrap, unproven.
|
|
115
|
+
compute_capability: the card's CUDA compute capability, or None when the
|
|
116
|
+
backend does not pin one.
|
|
117
|
+
note: what a user needs to know when a combination is refused.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
provides: frozenset[str]
|
|
121
|
+
attempts: frozenset[str] = frozenset()
|
|
122
|
+
compute_capability: tuple[int, int] | None = None
|
|
123
|
+
note: str = ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
#: Minimum CUDA compute capability a plugin needs, with the reason.
|
|
127
|
+
#:
|
|
128
|
+
#: Chai-1r's bfloat16 assumption is spread through its modules rather than
|
|
129
|
+
#: sitting behind one argument, which is why the one-line precision patch that
|
|
130
|
+
#: makes Boltz-2 work on a T4 does not transfer to it.
|
|
131
|
+
PLUGIN_MIN_COMPUTE: dict[str, tuple[tuple[int, int], str]] = {
|
|
132
|
+
"chai1r": ((8, 0), "requires bfloat16, which needs Ampere (sm_80) or newer"),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
#: What each backend can run, stated from what its code actually builds.
|
|
137
|
+
#:
|
|
138
|
+
#: Kaggle is the sharp case. ``kaggle_kernel.build_kernel_script`` takes no
|
|
139
|
+
#: designer or validator argument at all and builds exactly two micromamba
|
|
140
|
+
#: environments — ``se3`` for RFdiffusion and ProteinMPNN, ``boltz`` for Boltz-2.
|
|
141
|
+
#: Asking it for BindCraft produced that same two-environment kernel, which
|
|
142
|
+
#: failed after roughly six minutes of environment building had already been
|
|
143
|
+
#: charged to the user's weekly quota.
|
|
144
|
+
BACKEND_CAPABILITIES: dict[str, BackendCapability] = {
|
|
145
|
+
"kaggle": BackendCapability(
|
|
146
|
+
provides=frozenset({"rfdiff_mpnn", "boltz2"}),
|
|
147
|
+
compute_capability=(7, 5),
|
|
148
|
+
note=(
|
|
149
|
+
"the Kaggle kernel builds exactly two environments, se3 "
|
|
150
|
+
"(RFdiffusion + ProteinMPNN) and boltz (Boltz-2), and nothing "
|
|
151
|
+
"selects a different one"
|
|
152
|
+
),
|
|
153
|
+
),
|
|
154
|
+
"colab": BackendCapability(
|
|
155
|
+
provides=frozenset({"rfdiff_mpnn", "boltz2"}),
|
|
156
|
+
compute_capability=(7, 5),
|
|
157
|
+
note="the generated notebook installs RFdiffusion, ProteinMPNN and Boltz-2 only",
|
|
158
|
+
),
|
|
159
|
+
"modal": BackendCapability(
|
|
160
|
+
provides=frozenset({"boltz2"}),
|
|
161
|
+
attempts=frozenset({"rfdiff_mpnn"}),
|
|
162
|
+
note=(
|
|
163
|
+
"the Modal image installs Boltz-2 on a CUDA devel base; the executor "
|
|
164
|
+
"would bootstrap RFdiffusion itself, which has not been run"
|
|
165
|
+
),
|
|
166
|
+
),
|
|
167
|
+
"local_docker": BackendCapability(
|
|
168
|
+
provides=frozenset({"boltz2"}),
|
|
169
|
+
attempts=frozenset({"rfdiff_mpnn"}),
|
|
170
|
+
note=(
|
|
171
|
+
"the image ships Boltz-2; the executor would bootstrap RFdiffusion "
|
|
172
|
+
"itself, which the CPU tests do not exercise"
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
# The mock backend synthesises results and runs no tool, so every name is
|
|
176
|
+
# accepted. That is what makes it useful for testing orchestration and
|
|
177
|
+
# useless as evidence, which its own output says on every row.
|
|
178
|
+
# Resolved when the table is read rather than frozen at import, so a
|
|
179
|
+
# registered third-party plugin is runnable against the mock backend --
|
|
180
|
+
# which is how anyone would first test one.
|
|
181
|
+
"mock": BackendCapability(
|
|
182
|
+
provides=frozenset(),
|
|
183
|
+
note="synthetic results; runs no tool",
|
|
184
|
+
),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def plugin_support(backend: str, plugin: str) -> tuple[str, str]:
|
|
189
|
+
"""Whether ``backend`` can run ``plugin``, and why.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
backend: runner name.
|
|
193
|
+
plugin: designer or validator name.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
``(SUPPORTED | UNTESTED | UNSUPPORTED, explanation)``. An unknown
|
|
197
|
+
backend is treated as untested rather than refused, so a third-party
|
|
198
|
+
runner registered through the entry points is never blocked by a table
|
|
199
|
+
it could not have been added to.
|
|
200
|
+
"""
|
|
201
|
+
# The mock backend runs no tool at all, so every name it is asked for is
|
|
202
|
+
# "supported" in the only sense that matters here -- including one this
|
|
203
|
+
# process learned about from an entry point. Its capability row carries an
|
|
204
|
+
# empty `provides` because the answer is not a fixed set.
|
|
205
|
+
if backend == "mock":
|
|
206
|
+
return SUPPORTED, ""
|
|
207
|
+
capability = BACKEND_CAPABILITIES.get(backend)
|
|
208
|
+
if capability is None:
|
|
209
|
+
return UNTESTED, f"{backend!r} is not a bundled backend, so its environment is unknown"
|
|
210
|
+
|
|
211
|
+
required = PLUGIN_MIN_COMPUTE.get(plugin)
|
|
212
|
+
if (
|
|
213
|
+
required is not None
|
|
214
|
+
and capability.compute_capability is not None
|
|
215
|
+
and capability.compute_capability < required[0]
|
|
216
|
+
):
|
|
217
|
+
major, minor = required[0]
|
|
218
|
+
have = ".".join(str(n) for n in capability.compute_capability)
|
|
219
|
+
return UNSUPPORTED, (
|
|
220
|
+
f"{plugin} {required[1]}; {backend} pins a card at compute capability "
|
|
221
|
+
f"{have}, below sm_{major}{minor}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if plugin in capability.provides:
|
|
225
|
+
return SUPPORTED, ""
|
|
226
|
+
if plugin in capability.attempts:
|
|
227
|
+
return UNTESTED, (
|
|
228
|
+
f"{backend} has no prepared environment for {plugin}; the executor "
|
|
229
|
+
f"would try to bootstrap it, which has not been run end to end"
|
|
230
|
+
)
|
|
231
|
+
return UNSUPPORTED, (
|
|
232
|
+
f"{backend} cannot run {plugin}: {capability.note}. "
|
|
233
|
+
f"It provides: {', '.join(sorted(capability.provides))}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _load(group: str, name: str) -> type:
|
|
238
|
+
"""Resolve a plugin class by entry-point group + name."""
|
|
239
|
+
discovered: list[Any] = []
|
|
240
|
+
try:
|
|
241
|
+
discovered = list(entry_points(group=group))
|
|
242
|
+
except Exception as exc: # pragma: no cover - metadata edge cases
|
|
243
|
+
LOG.warning("could not read the %s entry-point group (%s)", group, exc)
|
|
244
|
+
for ep in discovered:
|
|
245
|
+
if ep.name != name:
|
|
246
|
+
continue
|
|
247
|
+
try:
|
|
248
|
+
return ep.load() # type: ignore[no-any-return]
|
|
249
|
+
except Exception as exc:
|
|
250
|
+
# A registered plugin that fails to import is a broken plugin, not
|
|
251
|
+
# an unknown one. Swallowing this reported "unknown %s plugin",
|
|
252
|
+
# which names the opposite of the cause and sends the reader to
|
|
253
|
+
# check their spelling.
|
|
254
|
+
LOG.warning(
|
|
255
|
+
"entry point %s:%s is registered but failed to load (%s); "
|
|
256
|
+
"falling back to the bundled implementation if there is one",
|
|
257
|
+
group,
|
|
258
|
+
name,
|
|
259
|
+
exc,
|
|
260
|
+
)
|
|
261
|
+
# Fallback: import the bundled path directly.
|
|
262
|
+
target = _FALLBACK.get(group, {}).get(name)
|
|
263
|
+
if target is None:
|
|
264
|
+
raise ValueError(f"unknown {group} plugin: {name!r}")
|
|
265
|
+
module_path, _, cls_name = target.partition(":")
|
|
266
|
+
import importlib
|
|
267
|
+
|
|
268
|
+
return getattr(importlib.import_module(module_path), cls_name) # type: ignore[no-any-return]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_designer(name: str) -> Any:
|
|
272
|
+
"""Instantiate a designer plugin by name."""
|
|
273
|
+
return _load("bindsight.designers", name)()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
#: Constructor kwargs whose silent loss changes what a result means.
|
|
277
|
+
#:
|
|
278
|
+
#: Dropping ``gpu_type`` costs an accurate cost estimate. Dropping
|
|
279
|
+
#: ``bindsight_wheel`` means the GPU installs the repository's default branch
|
|
280
|
+
#: instead of the code in front of you, and the run's own summary went on
|
|
281
|
+
#: reporting "working-tree wheel ..." because it described what was *asked for*
|
|
282
|
+
#: rather than what the runner took. That is the failure the wheel exists to
|
|
283
|
+
#: prevent, so losing it has to be loud.
|
|
284
|
+
_PROVENANCE_KWARGS = frozenset({"bindsight_wheel", "bindsight_ref"})
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def runner_accepts(name: str, param: str) -> bool:
|
|
288
|
+
"""Whether backend ``name``'s constructor takes ``param``.
|
|
289
|
+
|
|
290
|
+
Callers that *report* on a kwarg need this, because passing one to
|
|
291
|
+
:func:`get_runner` is not the same as the runner using it.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
name: the runner's entry-point name.
|
|
295
|
+
param: the constructor parameter to look for.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
True when the runner accepts it.
|
|
299
|
+
"""
|
|
300
|
+
import inspect
|
|
301
|
+
|
|
302
|
+
return param in inspect.signature(_load("bindsight.runners", name)).parameters
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def get_runner(name: str, **kwargs: Any) -> Any:
|
|
306
|
+
"""Instantiate a runner backend by name.
|
|
307
|
+
|
|
308
|
+
Runners have heterogeneous constructors (e.g. ``MockRunner`` takes none of
|
|
309
|
+
the design kwargs), so only the kwargs a given runner actually accepts are
|
|
310
|
+
forwarded. Anything dropped that changes what a result *means* is logged
|
|
311
|
+
rather than discarded quietly: ``ModalRunner`` takes no ``bindsight_wheel``,
|
|
312
|
+
so a benchmark that built one from the working tree had it silently
|
|
313
|
+
thrown away and the GPU installed the default branch, while the published
|
|
314
|
+
summary still named the wheel.
|
|
315
|
+
"""
|
|
316
|
+
import inspect
|
|
317
|
+
|
|
318
|
+
cls = _load("bindsight.runners", name)
|
|
319
|
+
params = inspect.signature(cls).parameters
|
|
320
|
+
accepted = {k: v for k, v in kwargs.items() if k in params}
|
|
321
|
+
|
|
322
|
+
dropped = sorted(k for k in kwargs if k not in params and kwargs[k] is not None)
|
|
323
|
+
for key in dropped:
|
|
324
|
+
if key in _PROVENANCE_KWARGS:
|
|
325
|
+
LOG.warning(
|
|
326
|
+
"backend %r does not accept %s, so it was discarded: the remote job "
|
|
327
|
+
"will install bindsight from its own default source, not from the "
|
|
328
|
+
"code that launched it. Results from this run cannot be attributed "
|
|
329
|
+
"to this working tree.",
|
|
330
|
+
name,
|
|
331
|
+
key,
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
LOG.debug("backend %r does not accept %s; ignored", name, key)
|
|
335
|
+
return cls(**accepted)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Provenance subsystem.
|
|
4
|
+
|
|
5
|
+
The :mod:`bindsight.provenance` package owns the inter-module contract: every
|
|
6
|
+
stage in the pipeline emits a :class:`StageRecord` into a single
|
|
7
|
+
``run_manifest.jsonld`` file (PROV-O JSON-LD), and the final stage packages
|
|
8
|
+
everything as an `RO-Crate <https://www.researchobject.org/ro-crate/>`_.
|
|
9
|
+
|
|
10
|
+
This is the audit trail that lets a reviewer, clinician, or future user walk
|
|
11
|
+
from a designed binder PDB back to the patient cohort it came from.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from bindsight.provenance.manifest import (
|
|
15
|
+
ContainerRef,
|
|
16
|
+
InputRef,
|
|
17
|
+
Manifest,
|
|
18
|
+
OutputRef,
|
|
19
|
+
StageRecord,
|
|
20
|
+
ToolRef,
|
|
21
|
+
new_manifest,
|
|
22
|
+
sha256_file,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"ContainerRef",
|
|
27
|
+
"InputRef",
|
|
28
|
+
"Manifest",
|
|
29
|
+
"OutputRef",
|
|
30
|
+
"StageRecord",
|
|
31
|
+
"ToolRef",
|
|
32
|
+
"new_manifest",
|
|
33
|
+
"sha256_file",
|
|
34
|
+
]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
"""Append a stage to a run's manifest, from wherever that stage ran.
|
|
4
|
+
|
|
5
|
+
The provenance chain is the project's central claim: a reader should be able to
|
|
6
|
+
walk from a designed binder back to the patient samples it came from. That only
|
|
7
|
+
works if every stage records itself.
|
|
8
|
+
|
|
9
|
+
Until now only ``bindsight run`` did. The individual subcommands — the ones the
|
|
10
|
+
README Quickstart tells you to use — left the manifest stopped at ``discover``,
|
|
11
|
+
so following the documented path produced a run whose chain broke at the point
|
|
12
|
+
the interesting half begins. The stage-building logic already existed inside
|
|
13
|
+
``pipelines/full_run.py``; it was simply unreachable from the CLI.
|
|
14
|
+
|
|
15
|
+
This module is the seam. Each subcommand builds its own ``StageRecord`` and calls
|
|
16
|
+
:func:`append_stage`, which reads the manifest already on disk, adds the record,
|
|
17
|
+
and rewrites it. Appending is idempotent by stage name: re-running a stage
|
|
18
|
+
replaces its record rather than accumulating duplicates, so a run that was
|
|
19
|
+
validated twice does not claim to have two validation stages.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from bindsight.provenance.manifest import (
|
|
29
|
+
InputRef,
|
|
30
|
+
Manifest,
|
|
31
|
+
OutputRef,
|
|
32
|
+
StageRecord,
|
|
33
|
+
ToolRef,
|
|
34
|
+
sha256_file,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
LOG = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
#: ``record`` is the entry point every caller uses (the CLI imports this
|
|
40
|
+
#: module as ``provenance`` and calls it five times); it was absent from
|
|
41
|
+
#: ``__all__`` while four helpers nothing outside this module calls were in it.
|
|
42
|
+
__all__ = [
|
|
43
|
+
"MANIFEST_NAME",
|
|
44
|
+
"append_stage",
|
|
45
|
+
"input_ref",
|
|
46
|
+
"output_ref",
|
|
47
|
+
"record",
|
|
48
|
+
"stage_tool",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
#: The manifest filename inside a run directory.
|
|
52
|
+
MANIFEST_NAME = "run_manifest.jsonld"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def stage_tool(name: str, version: str | None = None) -> ToolRef:
|
|
56
|
+
"""A ToolRef for a bindsight stage, versioned by the package."""
|
|
57
|
+
from bindsight import __version__
|
|
58
|
+
|
|
59
|
+
return ToolRef(
|
|
60
|
+
name=name,
|
|
61
|
+
version=version or __version__,
|
|
62
|
+
license="AGPL-3.0-or-later",
|
|
63
|
+
repo_url="https://github.com/mikhaeelatefrizk/bindsight",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def output_ref(role: str, path: Path, *, run_dir: Path | str | None = None) -> OutputRef | None:
|
|
68
|
+
r"""An OutputRef for a file that exists, or ``None``.
|
|
69
|
+
|
|
70
|
+
Returning ``None`` for an absent file keeps a manifest from asserting an
|
|
71
|
+
artifact that was never written, which is the same class of error as
|
|
72
|
+
reporting a lookup that never ran.
|
|
73
|
+
|
|
74
|
+
``path`` is recorded relative to ``run_dir`` in POSIX form, because that is
|
|
75
|
+
what :class:`OutputRef` says it is: "Path relative to the run root". It used
|
|
76
|
+
to store ``str(path)`` unchanged, which produced repository-relative paths in
|
|
77
|
+
the launching platform's separator style -- ``runs\\join\\deg\\results.parquet``
|
|
78
|
+
on Windows. A manifest read on another machine then described a layout that
|
|
79
|
+
machine does not have, and the field's own description was false.
|
|
80
|
+
"""
|
|
81
|
+
path = Path(path)
|
|
82
|
+
if not path.is_file():
|
|
83
|
+
return None
|
|
84
|
+
from bindsight.provenance.fragments import media_type_for
|
|
85
|
+
|
|
86
|
+
recorded = path
|
|
87
|
+
if run_dir is not None:
|
|
88
|
+
try:
|
|
89
|
+
recorded = path.resolve().relative_to(Path(run_dir).resolve())
|
|
90
|
+
except ValueError:
|
|
91
|
+
# Outside the run directory (a carried-in cohort, say). Keep the path
|
|
92
|
+
# as given rather than inventing a relationship that does not hold.
|
|
93
|
+
recorded = path
|
|
94
|
+
return OutputRef(
|
|
95
|
+
role=role,
|
|
96
|
+
path=recorded.as_posix(),
|
|
97
|
+
sha256=sha256_file(path),
|
|
98
|
+
bytes=path.stat().st_size,
|
|
99
|
+
media_type=media_type_for(path),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def append_stage(run_dir: Path | str, stage: StageRecord) -> Path | None:
|
|
104
|
+
"""Add ``stage`` to the run's manifest, replacing any record of the same name.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
run_dir: the run directory holding ``run_manifest.jsonld``.
|
|
108
|
+
stage: the record to add.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
The manifest path, or ``None`` if there is no manifest to append to —
|
|
112
|
+
which happens when a stage is run against a directory ``discover`` never
|
|
113
|
+
produced. That is logged rather than raised, because failing a completed
|
|
114
|
+
design job over its bookkeeping would be worse than the missing record.
|
|
115
|
+
"""
|
|
116
|
+
run_dir = Path(run_dir)
|
|
117
|
+
path = run_dir / MANIFEST_NAME
|
|
118
|
+
if not path.is_file():
|
|
119
|
+
LOG.warning(
|
|
120
|
+
"no %s in %s; the %s stage ran but could not be recorded. Run "
|
|
121
|
+
"`bindsight discover` first so the chain has a root.",
|
|
122
|
+
MANIFEST_NAME,
|
|
123
|
+
run_dir,
|
|
124
|
+
stage.name,
|
|
125
|
+
)
|
|
126
|
+
return None
|
|
127
|
+
try:
|
|
128
|
+
manifest = Manifest.read(path)
|
|
129
|
+
except Exception as e: # a damaged manifest must not lose completed work
|
|
130
|
+
LOG.warning("could not read %s (%s); leaving it untouched", path, e)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# Replace rather than accumulate: re-running validate must not make the run
|
|
134
|
+
# claim two validation stages.
|
|
135
|
+
manifest.stages = [s for s in manifest.stages if s.name != stage.name]
|
|
136
|
+
manifest.append(stage)
|
|
137
|
+
manifest.write(path)
|
|
138
|
+
LOG.info("recorded stage %r in %s", stage.name, path)
|
|
139
|
+
return path
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def record(
|
|
143
|
+
run_dir: Path | str,
|
|
144
|
+
*,
|
|
145
|
+
name: str,
|
|
146
|
+
tool: str,
|
|
147
|
+
outputs: dict[str, Path],
|
|
148
|
+
inputs: dict[str, Path] | None = None,
|
|
149
|
+
params: dict[str, Any] | None = None,
|
|
150
|
+
notes: str | None = None,
|
|
151
|
+
) -> Path | None:
|
|
152
|
+
"""Build and append a completed stage record in one call.
|
|
153
|
+
|
|
154
|
+
Outputs that do not exist on disk are omitted rather than asserted, and so
|
|
155
|
+
are inputs: a manifest must not claim to have consumed a file that is not
|
|
156
|
+
there.
|
|
157
|
+
|
|
158
|
+
``inputs`` exists because it did not. Every stage the CLI recorded through
|
|
159
|
+
this helper carried an empty ``prov:used``, so the provenance graph had no
|
|
160
|
+
edge from a stage to the artifacts it read -- and a graph whose whole purpose
|
|
161
|
+
is "show me what produced this" cannot answer that with no incoming edges.
|
|
162
|
+
"""
|
|
163
|
+
stage = StageRecord(name=name, tool=stage_tool(tool), params=params or {})
|
|
164
|
+
stage.notes = notes
|
|
165
|
+
stage.inputs = [
|
|
166
|
+
in_ref
|
|
167
|
+
for role, path in (inputs or {}).items()
|
|
168
|
+
if (in_ref := input_ref(role, path, run_dir=run_dir)) is not None
|
|
169
|
+
]
|
|
170
|
+
refs = [
|
|
171
|
+
out_ref
|
|
172
|
+
for role, path in outputs.items()
|
|
173
|
+
if (out_ref := output_ref(role, path, run_dir=run_dir)) is not None
|
|
174
|
+
]
|
|
175
|
+
stage.mark_completed(outputs=refs)
|
|
176
|
+
return append_stage(run_dir, stage)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def input_ref(role: str, path: Path, *, run_dir: Path | str | None = None) -> InputRef | None:
|
|
180
|
+
"""An InputRef for a file that exists, or ``None``.
|
|
181
|
+
|
|
182
|
+
The mirror of :func:`output_ref`, and recorded the same way, so the two sides
|
|
183
|
+
of the provenance graph describe paths identically.
|
|
184
|
+
"""
|
|
185
|
+
path = Path(path)
|
|
186
|
+
if not path.is_file():
|
|
187
|
+
return None
|
|
188
|
+
recorded = path
|
|
189
|
+
if run_dir is not None:
|
|
190
|
+
try:
|
|
191
|
+
recorded = path.resolve().relative_to(Path(run_dir).resolve())
|
|
192
|
+
except ValueError:
|
|
193
|
+
recorded = path
|
|
194
|
+
return InputRef(
|
|
195
|
+
role=role,
|
|
196
|
+
path=recorded.as_posix(),
|
|
197
|
+
sha256=sha256_file(path),
|
|
198
|
+
bytes=path.stat().st_size,
|
|
199
|
+
)
|