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
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
+ )