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/config.py ADDED
@@ -0,0 +1,451 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """User-facing pipeline configuration.
4
+
5
+ The YAML configs under ``examples/`` (and any user-authored config) are
6
+ parsed and validated through these Pydantic v2 models. Validation runs at
7
+ load time so misconfigured runs fail loudly *before* any compute is spent.
8
+
9
+ Add new options here, not by inventing keys in the YAML. Schema additions are
10
+ backwards-compatible if every new field has a default; otherwise bump the
11
+ schema version (we don't track one yet — first add when we ship a breaking
12
+ change).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ import yaml
20
+
21
+ # `ValidationInfo` comes from `pydantic`, not from `pydantic_core.core_schema`.
22
+ # The latter is pydantic's private internal surface: it is re-exported here
23
+ # only as an implementation detail, and importing it directly means a
24
+ # pydantic release that moves it breaks this package with no deprecation.
25
+ from pydantic import (
26
+ BaseModel,
27
+ ConfigDict,
28
+ Field,
29
+ field_validator,
30
+ model_validator,
31
+ )
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Inputs
36
+ # ---------------------------------------------------------------------------
37
+ class GDCSource(BaseModel):
38
+ """Auto-download spec for a real TCGA cohort from NIH/GDC.
39
+
40
+ When set on :class:`InputsConfig` and the ``counts``/``design`` files are
41
+ absent, ``bindsight discover`` fetches the cohort (STAR - Counts) from the
42
+ GDC open-access API and writes the files before running DESeq2.
43
+ """
44
+
45
+ model_config = ConfigDict(extra="forbid")
46
+
47
+ project: str = Field(..., description="GDC project id, e.g. 'TCGA-BRCA'.")
48
+ n_tumor: int = Field(20, ge=2, description="Number of Primary Tumor samples to fetch.")
49
+ n_normal: int = Field(20, ge=2, description="Number of Solid Tissue Normal samples to fetch.")
50
+ gene_types: list[str] = Field(
51
+ default_factory=lambda: ["protein_coding"],
52
+ description="Gene biotypes to keep ([] keeps all). Protein-coding covers the "
53
+ "surface-antigen targets and keeps DESeq2 tractable.",
54
+ )
55
+
56
+
57
+ class InputsConfig(BaseModel):
58
+ """Input file paths."""
59
+
60
+ model_config = ConfigDict(extra="forbid")
61
+
62
+ counts: Path = Field(
63
+ ...,
64
+ description="Path to the gene × sample counts matrix (TSV or TSV.gz). "
65
+ "Rows are gene IDs, columns are sample IDs, values are integer counts.",
66
+ )
67
+ design: Path = Field(
68
+ ...,
69
+ description="Path to the sample design TSV. Rows are samples, columns include "
70
+ "at least the factors named in ``params.deg.design_formula``.",
71
+ )
72
+ download: GDCSource | None = Field(
73
+ None,
74
+ description="Optional: auto-download a real TCGA cohort from GDC when the "
75
+ "counts/design files are missing.",
76
+ )
77
+
78
+
79
+ # Open Targets' tractability vocabulary. The API reports modalities by these
80
+ # two-letter codes only, so any other spelling ("Antibody") intersects nothing and
81
+ # silently drops every candidate — the filter must reject it at load time instead.
82
+ TRACTABLE_MODALITIES: dict[str, str] = {
83
+ "AB": "antibody",
84
+ "OC": "other clinical modality",
85
+ "PR": "PROTAC",
86
+ "SM": "small molecule",
87
+ }
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Per-stage parameters
92
+ # ---------------------------------------------------------------------------
93
+ class DEGParams(BaseModel):
94
+ """Differential expression parameters."""
95
+
96
+ model_config = ConfigDict(extra="forbid")
97
+
98
+ design_formula: str = Field(..., description="Patsy formula, e.g. '~ condition'.")
99
+ contrast: list[str] = Field(
100
+ ...,
101
+ min_length=3,
102
+ max_length=3,
103
+ description="Three-element list ``[factor, numerator_level, denominator_level]``.",
104
+ )
105
+ fdr_threshold: float = Field(0.05, ge=0.0, le=1.0)
106
+ log2fc_threshold: float = Field(1.0, ge=0.0)
107
+ min_replicates: int = Field(3, ge=2)
108
+ # The low-count filter: only genes with at least ``min_count`` counts in at
109
+ # least ``min_replicates`` samples are retained. 0 disables it. (This comment
110
+ # opened "When True" from when the field was a boolean.)
111
+ min_count: int = Field(10, ge=0)
112
+ # Worker processes pydeseq2 may use. None means "every core", which is the
113
+ # right default on a server and the wrong one on the laptop this pipeline is
114
+ # meant to run on: a multi-hour job that pegs every core makes the machine
115
+ # unusable. Set this to leave headroom.
116
+ n_cpus: int | None = Field(None, ge=1)
117
+
118
+
119
+ class TargetDiscoveryParams(BaseModel):
120
+ """Target discovery / surfaceome / specificity parameters."""
121
+
122
+ model_config = ConfigDict(extra="forbid")
123
+
124
+ # Surfaceome filter
125
+ require_surfy: bool = True
126
+ surfy_allow_offline_fallback: bool = Field(
127
+ False,
128
+ description="Read ONLY when ``use_extended_surfaceome`` is False. If "
129
+ "True, fall back to the small bundled SURFY list when the user cache is "
130
+ "empty; production runs should leave it False so the pipeline fails fast "
131
+ "if the cache isn't populated. Under the default "
132
+ "``use_extended_surfaceome: true`` the extended list is loaded instead "
133
+ "and this field is not consulted at all, so setting it there has no "
134
+ "effect — see bindsight/pipelines/discover.py::_resolve_surfaceome.",
135
+ )
136
+
137
+ # Tissue-specificity filter (low expression in vital tissues)
138
+ vital_tissues: list[str] = Field(
139
+ default_factory=lambda: [
140
+ "heart_left_ventricle",
141
+ "brain_cortex",
142
+ "liver",
143
+ "lung",
144
+ ]
145
+ )
146
+ vital_tissue_max_tpm: float = Field(5.0, ge=0.0)
147
+ # A safety gate that cannot measure a gene has not cleared it. When GTEx has
148
+ # no median-TPM entry for a candidate, treat that as unassessed and withhold
149
+ # it from design carry-forward rather than passing it silently. This matches
150
+ # the contract documented on ``GTExTissueExpression.assess``: only a measured
151
+ # ``safe`` verdict may be presented as having cleared the gate. Set False to
152
+ # accept unmeasured candidates, which is a deliberate loosening.
153
+ gtex_require_measured: bool = True
154
+
155
+ # The same contract for Open Targets. ``n_safety_events`` defaults to zero
156
+ # when a lookup returns nothing, so an unreachable API would otherwise
157
+ # promote every affected gene to "no known safety events" and pass it
158
+ # through a filter built to exclude exactly that. Withhold instead, and
159
+ # record the disposition as ``safety_unassessed`` rather than
160
+ # ``fails_safety`` — a network outage is not a finding about the gene. Set
161
+ # False to accept unmeasured candidates, which is a deliberate loosening.
162
+ open_targets_require_measured: bool = True
163
+
164
+ # Structure-confidence (disorder) filter — AlphaFold pLDDT (0-100). pLDDT is
165
+ # always computed and surfaced (mean_plddt column); this only gates carry-
166
+ # forward. 0 disables the gate (default). A typical disorder threshold is
167
+ # ~50 (very low confidence / likely disordered) to ~70 (confident).
168
+ min_mean_plddt: float = Field(0.0, ge=0.0, le=100.0)
169
+
170
+ # Membrane-topology (extracellular-domain) awareness — UniProt. A binder can
171
+ # only reach the extracellular part of a surface protein. When enabled,
172
+ # discovery annotates each candidate's extracellular ranges and targets the
173
+ # ECD for whole-surface design. Off by default (requires UniProt network).
174
+ use_uniprot_topology: bool = False
175
+ # Gate: drop candidates with no annotated extracellular domain (only meaningful
176
+ # when use_uniprot_topology is True). Off by default.
177
+ require_extracellular_domain: bool = False
178
+
179
+ # Cap on how many significant DEGs are carried into target enrichment,
180
+ # ranked by the combined score pi = log2fc x -log10(padj). This is the most
181
+ # consequential filter in discovery: a gene outside the cut never becomes a
182
+ # candidate at all, no matter how surface-exposed or tractable it is. It is
183
+ # a declared parameter rather than a module constant precisely so it lands
184
+ # in the run manifest and can be reported as a gate, not disappear into the
185
+ # ranking.
186
+ enrich_top_k: int = Field(300, ge=1)
187
+ # Whether the surfaceome filter runs BEFORE the enrichment cut.
188
+ #
189
+ # It used to run after, which meant surface antigens competed against every
190
+ # gene in the genome for the enrichment slots. In a real cohort roughly four
191
+ # thousand genes are significant and only a few dozen of the top three
192
+ # hundred are surface proteins, so most of the surfaceome was discarded
193
+ # before it was ever looked at. Measured effect: NECTIN4 in bladder cancer,
194
+ # the target of an approved drug for that exact indication, ranks 259th of
195
+ # 2,104 surface proteins and was still excluded.
196
+ #
197
+ # The old ordering was forced rather than careless: the filter needs UniProt
198
+ # accessions, and those only existed after Open Targets enrichment. The
199
+ # vendored Ensembl-to-accession map removes that dependency, so the cut can
200
+ # now be spent on genes that could actually be targets. The number of Open
201
+ # Targets calls is unchanged.
202
+ surfaceome_prefilter: bool = True
203
+ # Whether to use the extended surfaceome (SURFY plus UniProt's curated
204
+ # cell-membrane annotations) rather than SURFY alone.
205
+ #
206
+ # SURFY is prediction-based and demonstrably incomplete. The rediscovery study
207
+ # measured CA9 at log2fc 9.58 in clear-cell kidney — the largest effect in the
208
+ # panel — and could not surface it at any expression level, because the
209
+ # accession is not in the list. STEAP1 is in the same position. The extension
210
+ # adds 1,915 accessions and makes both reachable.
211
+ #
212
+ # Set False to reproduce a SURFY-only run exactly; SURFY membership is
213
+ # preserved in the extension's source column either way.
214
+ use_extended_surfaceome: bool = True
215
+
216
+ # Open Targets enrichment
217
+ use_open_targets: bool = True
218
+ require_tractable_modality: list[str] = Field(
219
+ default_factory=lambda: ["AB"],
220
+ description="Open Targets tractability modality codes a candidate must have "
221
+ "(see TRACTABLE_MODALITIES). [] disables the filter.",
222
+ )
223
+ max_safety_events: int = Field(5, ge=0)
224
+
225
+ @field_validator("require_tractable_modality")
226
+ @classmethod
227
+ def _check_modalities(cls, v: list[str]) -> list[str]:
228
+ unknown = [m for m in v if m not in TRACTABLE_MODALITIES]
229
+ if unknown:
230
+ valid = ", ".join(f"{code} ({name})" for code, name in TRACTABLE_MODALITIES.items())
231
+ raise ValueError(
232
+ f"unknown tractability modality {unknown}: Open Targets reports "
233
+ f"modalities as {valid}. An unrecognised value matches nothing and "
234
+ "would drop every candidate."
235
+ )
236
+ return v
237
+
238
+ # Normal-tissue safety (GTEx) — on-target/off-tumor toxicity. When enabled,
239
+ # candidates whose median expression in any vital tissue exceeds
240
+ # ``vital_tissue_max_tpm`` are flagged ``high_normal_tissue_expression`` and
241
+ # dropped from design. Off by default (requires the GTEx download). Wires the
242
+ # ``vital_tissues`` / ``vital_tissue_max_tpm`` knobs above.
243
+ use_gtex_safety: bool = False
244
+
245
+ # SURFACE-Bind site lookup
246
+ require_surface_bind_site: bool = True
247
+ min_surface_bind_score: float = Field(0.5, ge=0.0, le=1.0)
248
+
249
+ # Pipeline cap
250
+ top_n: int = Field(5, ge=1)
251
+
252
+
253
+ def _registered_or_raise(value: str, kind: str, group: str) -> str:
254
+ """Accept a plugin name the registry can resolve; reject anything else.
255
+
256
+ These fields were `Literal`s, which rejected every plugin registered through
257
+ an entry point -- the mechanism `bindsight.plugins` exists for, and that
258
+ `docs/use-cases.md` documents with a worked example. Dropping the `Literal`
259
+ restored the capability and would have dropped the validation with it, so a
260
+ misspelled bundled name would have reached the loader and failed there with
261
+ a less useful message.
262
+
263
+ Imported inside the function because `bindsight.config` is imported by
264
+ nearly everything and the registry reads installed metadata.
265
+ """
266
+ from bindsight.plugins import registered
267
+
268
+ known = registered(group)
269
+ if value in known:
270
+ return value
271
+ raise ValueError(
272
+ f"unknown {kind} {value!r}; this installation offers "
273
+ f"{', '.join(sorted(known))}. A third-party {kind} is selected by "
274
+ f"registering it in the {group!r} entry-point group."
275
+ )
276
+
277
+
278
+ class DesignParams(BaseModel):
279
+ """De novo binder design parameters (consumed by the GPU half)."""
280
+
281
+ model_config = ConfigDict(extra="forbid")
282
+
283
+ # Validated against the plugin registry rather than a `Literal`, because a
284
+ # `Literal` rejected any designer registered through the
285
+ # `bindsight.designers` entry-point group -- the mechanism `bindsight.plugins`
286
+ # exists for and `docs/use-cases.md` documents step by step. The loader
287
+ # worked; nothing could reach it.
288
+ designer: str = "rfdiff_mpnn"
289
+ n_trajectories: int = Field(50, ge=1)
290
+ binder_length_min: int = Field(50, ge=20)
291
+ binder_length_max: int = Field(100, ge=20)
292
+ seed: int = 42
293
+
294
+ # GPU the run is costed against. None resolves to the backend's default in
295
+ # bindsight.cost, which is how every run behaved before this field existed;
296
+ # the --cheap profile sets it to "T4" so the estimate reflects the hardware
297
+ # the profile actually targets instead of quoting A100 prices.
298
+ gpu_type: str | None = None
299
+
300
+ # ESM-2 pre-screen: keep only this many designs for validation. None (the
301
+ # default) validates every design, so behaviour is unchanged unless asked
302
+ # for. See bindsight.design.prescreen.
303
+ prescreen_top_k: int | None = Field(None, ge=1)
304
+
305
+ # A field validator fires only when its own field is supplied. Pydantic does
306
+ # not validate defaults, so `DesignParams(binder_length_min=200)` left the
307
+ # max at its default of 100 and this check never ran: the object was built
308
+ # with min > max and the designer was handed an empty length range. An
309
+ # after-validator runs on the assembled model, so it sees the pair however
310
+ # either half arrived.
311
+ @field_validator("designer")
312
+ @classmethod
313
+ def _designer_is_registered(cls, v: str) -> str:
314
+ return _registered_or_raise(v, "designer", "bindsight.designers")
315
+
316
+ @model_validator(mode="after")
317
+ def _check_length_range(self) -> DesignParams:
318
+ if self.binder_length_max < self.binder_length_min:
319
+ raise ValueError(
320
+ f"binder_length_max ({self.binder_length_max}) must be >= "
321
+ f"binder_length_min ({self.binder_length_min})"
322
+ )
323
+ return self
324
+
325
+
326
+ class ValidateParams(BaseModel):
327
+ """Validator parameters."""
328
+
329
+ model_config = ConfigDict(extra="forbid")
330
+
331
+ validator: str = "boltz2"
332
+
333
+ @field_validator("validator")
334
+ @classmethod
335
+ def _validator_is_registered(cls, v: str) -> str:
336
+ return _registered_or_raise(v, "validator", "bindsight.validators")
337
+
338
+ # How many structures the validator draws per binder, and how many of those
339
+ # it diffuses at once.
340
+ #
341
+ # Boltz-2 builds structures by diffusion, so one draw is a sample rather
342
+ # than a measurement. The calibration measured what that costs: refolding
343
+ # twenty sequences moved ipTM by a median of 0.172 and flipped
344
+ # 4 of 20 verdicts at the threshold below. Averaging k draws cuts that spread
345
+ # by sqrt(k) and costs GPU time close to linearly, so the default stays 1
346
+ # and the choice is the user's — but it is now a choice rather than
347
+ # something only the executor could reach.
348
+ #
349
+ # The batch size is not a performance knob: the sampler draws noise shaped
350
+ # by the batch, so a seed consumes the RNG stream differently at batch 1 and
351
+ # batch 5. Sequential by default because the free-tier T4 this project
352
+ # targets peaked at 10,917 MiB of 15,360 on a single draw.
353
+ diffusion_samples: int = Field(1, ge=1)
354
+ max_parallel_samples: int = Field(1, ge=1)
355
+ # Quality bars applied to validated binders. Every row is annotated with
356
+ # ``passes_thresholds`` and a reason regardless of the flag — the bars are
357
+ # descriptive by default. ``apply_thresholds`` additionally moves failing
358
+ # designs out of ``validated.parquet`` and into
359
+ # ``excluded_by_thresholds.parquet`` beside it, reasons attached, so they are
360
+ # set aside rather than silently dropped.
361
+ iptm_threshold: float = Field(0.65, ge=0.0, le=1.0)
362
+ pae_interaction_threshold: float = Field(8.0, ge=0.0)
363
+ apply_thresholds: bool = False
364
+
365
+
366
+ class RankWeights(BaseModel):
367
+ """Composite-score weights.
368
+
369
+ The composite is a weighted average over the components *present* for a row
370
+ (missing metrics are excluded, not penalised), so the weights need not sum to
371
+ 1.0 — they are renormalised per row. ``developability`` only contributes when
372
+ a binder sequence is available, so it is inert on sequence-less runs.
373
+ """
374
+
375
+ model_config = ConfigDict(extra="forbid")
376
+
377
+ log2fc_specificity: float = Field(0.25, ge=0.0, le=1.0)
378
+ iptm: float = Field(0.30, ge=0.0, le=1.0)
379
+ affinity: float = Field(0.30, ge=0.0, le=1.0)
380
+ sequence_recovery: float = Field(0.15, ge=0.0, le=1.0)
381
+ developability: float = Field(0.15, ge=0.0, le=1.0)
382
+
383
+ @model_validator(mode="after")
384
+ def _at_least_one_weight_is_positive(self) -> RankWeights:
385
+ """Reject an all-zero weighting instead of ranking by nothing.
386
+
387
+ Each weight is independently valid at 0.0 — turning a component off is a
388
+ real choice — but all of them at zero made every composite score NaN and
389
+ produced a "ranking" that was the input order with ranks stapled on. It
390
+ validated, ran, and published a shortlist.
391
+ """
392
+ if not any(getattr(self, field) > 0.0 for field in type(self).model_fields):
393
+ raise ValueError(
394
+ "every rank weight is zero, so no candidate can score above any "
395
+ "other and the resulting order would be the input order. Set at "
396
+ "least one weight above zero."
397
+ )
398
+ return self
399
+
400
+
401
+ class RankParams(BaseModel):
402
+ """Ranking parameters."""
403
+
404
+ model_config = ConfigDict(extra="forbid")
405
+
406
+ weights: RankWeights = Field(default_factory=RankWeights)
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # Top-level
411
+ # ---------------------------------------------------------------------------
412
+ class StageParams(BaseModel):
413
+ """Container for per-stage parameter blocks."""
414
+
415
+ model_config = ConfigDict(extra="forbid")
416
+
417
+ deg: DEGParams
418
+ target_discovery: TargetDiscoveryParams = Field(default_factory=TargetDiscoveryParams)
419
+ design: DesignParams = Field(default_factory=DesignParams)
420
+ validate_: ValidateParams = Field(default_factory=ValidateParams, alias="validate")
421
+ rank: RankParams = Field(default_factory=RankParams)
422
+
423
+
424
+ class RunConfig(BaseModel):
425
+ """Top-level pipeline configuration parsed from a YAML file."""
426
+
427
+ model_config = ConfigDict(extra="forbid")
428
+
429
+ name: str = Field(..., description="Human-readable run label, used in the manifest.")
430
+ out_dir: Path = Field(..., description="Output directory. Will be created.")
431
+ inputs: InputsConfig
432
+ params: StageParams
433
+ backend: str = "colab"
434
+
435
+ @field_validator("backend")
436
+ @classmethod
437
+ def _backend_is_registered(cls, v: str) -> str:
438
+ return _registered_or_raise(v, "backend", "bindsight.runners")
439
+
440
+ # Declared but not consumed by any stage. An example config described it as
441
+ # dropping to a T4 with fewer trajectories; the T4 is now the only GPU the
442
+ # Kaggle runner asks for, and nothing has ever read the flag. It is kept so
443
+ # existing run configs still load, and described accurately rather than
444
+ # advertising behaviour that does not exist.
445
+ cheap_profile: bool = Field(False, description="Reserved. No stage currently reads this.")
446
+
447
+ @classmethod
448
+ def from_yaml(cls, path: Path | str) -> RunConfig:
449
+ """Load and validate a config from a YAML file."""
450
+ text = Path(path).read_text(encoding="utf-8")
451
+ return cls.model_validate(yaml.safe_load(text))