bindsight 0.3.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. bindsight/README.md +44 -0
  2. bindsight/__init__.py +15 -0
  3. bindsight/benchmark/__init__.py +71 -0
  4. bindsight/benchmark/core.py +497 -0
  5. bindsight/benchmark/defaults.py +28 -0
  6. bindsight/benchmark/designer_bench.py +899 -0
  7. bindsight/benchmark/outcomes.py +508 -0
  8. bindsight/benchmark/panel.py +621 -0
  9. bindsight/benchmark/rediscovery.py +1153 -0
  10. bindsight/benchmark/statistics.py +622 -0
  11. bindsight/benchmark/study.py +1153 -0
  12. bindsight/benchmark/study_figures.py +164 -0
  13. bindsight/benchmark/study_report.py +607 -0
  14. bindsight/cli.py +2429 -0
  15. bindsight/config.py +451 -0
  16. bindsight/cost.py +258 -0
  17. bindsight/deg/__init__.py +7 -0
  18. bindsight/deg/pydeseq2_runner.py +285 -0
  19. bindsight/design/__init__.py +23 -0
  20. bindsight/design/_common.py +501 -0
  21. bindsight/design/bindcraft.py +78 -0
  22. bindsight/design/boltzgen.py +79 -0
  23. bindsight/design/developability.py +140 -0
  24. bindsight/design/embeddings.py +165 -0
  25. bindsight/design/prescreen.py +169 -0
  26. bindsight/design/protocol.py +111 -0
  27. bindsight/design/rfdiff_mpnn.py +93 -0
  28. bindsight/epitopes/__init__.py +7 -0
  29. bindsight/epitopes/surface_bind.py +134 -0
  30. bindsight/export/__init__.py +7 -0
  31. bindsight/export/ro_crate.py +595 -0
  32. bindsight/io/__init__.py +19 -0
  33. bindsight/io/cbioportal.py +118 -0
  34. bindsight/io/gdc.py +343 -0
  35. bindsight/io/paths.py +124 -0
  36. bindsight/pipelines/__init__.py +9 -0
  37. bindsight/pipelines/caveats.py +60 -0
  38. bindsight/pipelines/discover.py +1443 -0
  39. bindsight/pipelines/full_run.py +410 -0
  40. bindsight/plugins.py +335 -0
  41. bindsight/provenance/__init__.py +34 -0
  42. bindsight/provenance/append.py +199 -0
  43. bindsight/provenance/fragments.py +216 -0
  44. bindsight/provenance/manifest.py +506 -0
  45. bindsight/rank/__init__.py +7 -0
  46. bindsight/rank/scoring.py +355 -0
  47. bindsight/report/__init__.py +39 -0
  48. bindsight/report/format.py +100 -0
  49. bindsight/report/html.py +645 -0
  50. bindsight/report/showcase.py +653 -0
  51. bindsight/report/templates/report.css +67 -0
  52. bindsight/report/templates/report.html.j2 +420 -0
  53. bindsight/report/theme.py +231 -0
  54. bindsight/report/web/__init__.py +35 -0
  55. bindsight/report/web/app.py +624 -0
  56. bindsight/report/web/static/binder_viewer.js +245 -0
  57. bindsight/report/web/static/bindsight.css +455 -0
  58. bindsight/report/web/static/charts.js +425 -0
  59. bindsight/report/web/static/vendor/3Dmol-min.js +2 -0
  60. bindsight/report/web/static/vendor/3Dmol.LICENSE +90 -0
  61. bindsight/report/web/static/your_data_check.js +370 -0
  62. bindsight/report/web/templates/base.html.j2 +48 -0
  63. bindsight/report/web/templates/evidence.html.j2 +341 -0
  64. bindsight/report/web/templates/overview.html.j2 +140 -0
  65. bindsight/report/web/templates/runs.html.j2 +125 -0
  66. bindsight/report/web/templates/try.html.j2 +155 -0
  67. bindsight/report/web/templates/your_data.html.j2 +46 -0
  68. bindsight/runners/__init__.py +7 -0
  69. bindsight/runners/colab.py +158 -0
  70. bindsight/runners/job_exec.py +1110 -0
  71. bindsight/runners/kaggle.py +425 -0
  72. bindsight/runners/kaggle_kernel.py +489 -0
  73. bindsight/runners/local_docker.py +154 -0
  74. bindsight/runners/mock.py +167 -0
  75. bindsight/runners/modal_runner.py +199 -0
  76. bindsight/runners/notebook.py +116 -0
  77. bindsight/runners/notebook_content.py +217 -0
  78. bindsight/runners/protocol.py +81 -0
  79. bindsight/runners/source_wheel.py +104 -0
  80. bindsight/runners/tools.py +997 -0
  81. bindsight/structures/__init__.py +7 -0
  82. bindsight/structures/alphafolddb.py +93 -0
  83. bindsight/structures/plddt.py +92 -0
  84. bindsight/structures/topology.py +227 -0
  85. bindsight/surfaceome/__init__.py +42 -0
  86. bindsight/surfaceome/data/surfaceome_extended.tsv +4814 -0
  87. bindsight/surfaceome/data/surfy_offline.txt +17 -0
  88. bindsight/surfaceome/data/surfy_v1.ensembl.tsv +3380 -0
  89. bindsight/surfaceome/data/surfy_v1.uniprot.txt +2901 -0
  90. bindsight/surfaceome/surfy.py +441 -0
  91. bindsight/targets/__init__.py +7 -0
  92. bindsight/targets/ensembl_uniprot.py +56 -0
  93. bindsight/targets/gtex.py +250 -0
  94. bindsight/targets/open_targets.py +239 -0
  95. bindsight/validate/__init__.py +7 -0
  96. bindsight/validate/af2_ig.py +49 -0
  97. bindsight/validate/boltz2.py +256 -0
  98. bindsight/validate/chai1r.py +42 -0
  99. bindsight/validate/protocol.py +188 -0
  100. bindsight-0.3.5.data/data/bindsight_demo/config.yaml +54 -0
  101. bindsight-0.3.5.dist-info/METADATA +495 -0
  102. bindsight-0.3.5.dist-info/RECORD +106 -0
  103. bindsight-0.3.5.dist-info/WHEEL +4 -0
  104. bindsight-0.3.5.dist-info/entry_points.txt +20 -0
  105. bindsight-0.3.5.dist-info/licenses/COPYRIGHT +24 -0
  106. bindsight-0.3.5.dist-info/licenses/LICENSE +681 -0
@@ -0,0 +1,355 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Multi-objective ranking of validated binders.
4
+
5
+ Combines four orthogonal signals into a composite ``score``:
6
+
7
+ 1. **Upstream evidence** — DE log2FC × specificity penalty (vital-tissue baseline).
8
+ 2. **Structure quality** — iPTM, pTM, binder pLDDT, pAE_interaction, RMSD-to-designed.
9
+ 3. **Affinity** — ``affinity_pred_value`` and ``affinity_probability_binary``.
10
+ 4. **Sequence quality** — ProteinMPNN sequence recovery vs. backbone (when available).
11
+
12
+ ``affinity_pred_value`` is a log(IC50)-like quantity where **lower is stronger**,
13
+ so it is inverted before normalisation. Only a validator that genuinely predicts
14
+ affinity may populate it; structure-confidence metrics belong to component 2. Boltz-2
15
+ affinity prediction is ligand-only, so for protein binders this component is usually
16
+ absent and the composite is renormalised over the components that are present.
17
+
18
+ Each signal is min-max normalised to [0, 1] across the run, then combined with
19
+ user-configurable weights from :class:`bindsight.config.RankWeights`. The output
20
+ Parquet has BOTH the composite ``score`` and every component, so users can
21
+ re-rank by any single metric in the report.
22
+
23
+ The rank module deliberately does NOT pick one Right Answer; it produces a
24
+ defensible default and surfaces the components so the user can override.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from pathlib import Path
31
+
32
+ import pandas as pd
33
+
34
+ from bindsight.config import RankWeights
35
+ from bindsight.design.developability import developability
36
+
37
+ LOG = logging.getLogger(__name__)
38
+
39
+
40
+ def _minmax(s: pd.Series, *, invert: bool = False) -> pd.Series:
41
+ """Min-max normalise to [0, 1]. NaN stays NaN. Constant series → 0.5.
42
+
43
+ With ``invert=True``, lower values → 1.0 (used for "lower is better"
44
+ metrics like pAE_interaction or RMSD).
45
+ """
46
+ if s.isna().all():
47
+ return s
48
+ lo, hi = s.min(skipna=True), s.max(skipna=True)
49
+ if pd.isna(lo) or pd.isna(hi) or lo == hi:
50
+ return s.where(s.isna(), 0.5)
51
+ norm = (s - lo) / (hi - lo)
52
+ return 1.0 - norm if invert else norm
53
+
54
+
55
+ def rank_validated(
56
+ validated: pd.DataFrame,
57
+ candidates: pd.DataFrame | None = None,
58
+ *,
59
+ weights: RankWeights | None = None,
60
+ ) -> pd.DataFrame:
61
+ """Rank validated binders by a composite score; return a new DataFrame.
62
+
63
+ Args:
64
+ validated: rows produced by ``bindsight validate``. Must include at
65
+ least ``binder_id``, ``target_uniprot``, and any of the metric
66
+ columns (``iptm``, ``pae_interaction``, ``affinity_pred_value``,
67
+ ``affinity_probability_binary``, ``rmsd_to_designed``,
68
+ ``sequence_recovery``).
69
+ candidates: optional rows from ``bindsight discover`` (provides
70
+ ``log2fc``, ``symbol``, ``n_safety_events``). Joined on
71
+ ``target_uniprot`` if provided.
72
+ weights: composite-score weights. Defaults to the RankWeights defaults.
73
+
74
+ Returns:
75
+ DataFrame sorted by descending ``score``, with ``rank`` column added.
76
+ Columns include the composite ``score``, every component score
77
+ (``score_evidence``, ``score_structure``, ``score_affinity``,
78
+ ``score_sequence``), and the original metrics for transparency.
79
+ """
80
+ weights = weights or RankWeights()
81
+ df = validated.copy()
82
+
83
+ # Join in upstream evidence if available.
84
+ if candidates is not None and not candidates.empty:
85
+ keep_cols = [
86
+ c
87
+ for c in ("symbol", "uniprot_id", "log2fc", "padj", "n_safety_events")
88
+ if c in candidates.columns
89
+ ]
90
+ if "uniprot_id" in keep_cols:
91
+ cand = _one_row_per_accession(candidates[keep_cols])
92
+ df = df.merge(
93
+ cand,
94
+ left_on="target_uniprot",
95
+ right_on="uniprot_id",
96
+ how="left",
97
+ suffixes=("", "_cand"),
98
+ )
99
+ if "uniprot_id" in df.columns and "target_uniprot" in df.columns:
100
+ df = df.drop(columns=["uniprot_id"], errors="ignore")
101
+
102
+ # Component scores (each in [0, 1]; NaN if metric missing for the row).
103
+ df["score_evidence"] = _evidence_score(df)
104
+ df["score_structure"] = _structure_score(df)
105
+ df["score_affinity"] = _affinity_score(df)
106
+ df["score_sequence"] = (
107
+ _minmax(df["sequence_recovery"])
108
+ if "sequence_recovery" in df.columns
109
+ else pd.Series([float("nan")] * len(df))
110
+ )
111
+ # Developability (sequence biophysics) — only when the binder sequence is
112
+ # carried on the row. developability_score is already an absolute [0, 1].
113
+ if "sequence" in df.columns:
114
+ df["developability_score"] = df["sequence"].map(_developability_score)
115
+ df["score_developability"] = df["developability_score"]
116
+ else:
117
+ df["score_developability"] = pd.Series([float("nan")] * len(df))
118
+
119
+ # Composite. Weights apply to non-NaN components only; missing metrics
120
+ # don't penalise — they're just excluded from the weighted average.
121
+ component_cols = [
122
+ ("score_evidence", weights.log2fc_specificity),
123
+ ("score_structure", weights.iptm),
124
+ ("score_affinity", weights.affinity),
125
+ ("score_sequence", weights.sequence_recovery),
126
+ ("score_developability", weights.developability),
127
+ ]
128
+ composite = pd.Series([0.0] * len(df), index=df.index)
129
+ weight_sum = pd.Series([0.0] * len(df), index=df.index)
130
+ for col, w in component_cols:
131
+ if col in df.columns:
132
+ # Coerce first, then mask. The mask used to be taken from the raw
133
+ # column while the value was coerced with ``errors="coerce"``, so a
134
+ # non-numeric but non-null cell -- ``"N/A"``, an empty string, an
135
+ # object-dtype string surviving a parquet merge -- was ``notna()``
136
+ # and therefore counted its full weight in the denominator, while
137
+ # coercing to NaN and contributing 0.0 to the numerator. The binder
138
+ # was silently penalised by exactly that component's weight, which
139
+ # is the opposite of what the comment above promises. A value that
140
+ # is not a number is a missing measurement, and is excluded.
141
+ column = pd.to_numeric(df[col], errors="coerce").astype(float)
142
+ mask = column.notna()
143
+ composite = composite.add(column.fillna(0.0) * w, fill_value=0.0)
144
+ weight_sum = weight_sum + (mask.astype(float) * w)
145
+ df["score"] = composite / weight_sum.replace(0.0, pd.NA)
146
+
147
+ # Sort + add rank. ``kind="mergesort"`` because pandas' default is not
148
+ # stable: two binders with the same composite score were ordered by numpy's
149
+ # introsort internals, so the published rank among ties could differ between
150
+ # runs and between platforms for identical inputs. Stable means ties keep the
151
+ # order they arrived in, which is reproducible and explainable.
152
+ df = df.sort_values("score", ascending=False, na_position="last", kind="mergesort").reset_index(
153
+ drop=True
154
+ )
155
+ df["rank"] = range(1, len(df) + 1)
156
+
157
+ return df
158
+
159
+
160
+ def _developability_score(sequence: object) -> float:
161
+ """Absolute developability score in [0, 1] for a binder sequence; NaN if unavailable."""
162
+ if not isinstance(sequence, str) or not sequence:
163
+ return float("nan")
164
+ d = developability(sequence)
165
+ return d.developability_score if d is not None else float("nan")
166
+
167
+
168
+ def _fold_change_score(log2fc: pd.Series) -> pd.Series:
169
+ """Scale log2 fold change to [0, 1] against an absolute floor of zero.
170
+
171
+ Min-max normalisation is scale-free: it maps the smallest value in the table
172
+ to 0 and the largest to 1 regardless of how far apart they actually are. For
173
+ a per-binder metric across forty rows that is harmless. For log2 fold change
174
+ it is not, because the value is per *target*, so a two-target run has two
175
+ distinct values and min-max maps them onto exactly {0.0, 1.0}.
176
+
177
+ That is what happened on the first real two-target run. CA9 was
178
+ over-expressed 764-fold and CD70 304-fold; both are enormous, and min-max
179
+ scored the second one zero. The full 0.25 evidence weight swung on a log2fc
180
+ difference of 1.33, which pushed CD70 binders with an ipTM of 0.95 and a
181
+ 3.3 A interface below CA9 binders at 0.83 and 11.7 A. The ranking was being
182
+ driven by an artefact of normalisation rather than by evidence.
183
+
184
+ Anchoring the floor at zero fixes it without inventing a scale. A log2 fold
185
+ change of zero *is* an absolute zero point: no differential expression, no
186
+ evidence. The top of the range stays relative to the run, which is what
187
+ makes this a ranking rather than an absolute score, but nothing that is
188
+ genuinely over-expressed can be scored as though it were not.
189
+
190
+ Down-regulated targets score zero rather than negative: this component asks
191
+ how strong the tumour-selective evidence is, and a gene expressed less in
192
+ tumour has none.
193
+
194
+ Args:
195
+ log2fc: per-row log2 fold change, possibly with missing values.
196
+
197
+ Returns:
198
+ A [0, 1] series, NaN preserved.
199
+ """
200
+ values = pd.to_numeric(log2fc, errors="coerce")
201
+ if values.isna().all():
202
+ return values
203
+ top = values.max(skipna=True)
204
+ if pd.isna(top) or top <= 0:
205
+ # Nothing in the table is over-expressed, so nothing carries evidence.
206
+ return values.where(values.isna(), 0.0)
207
+ return (values.clip(lower=0.0) / top).where(values.notna())
208
+
209
+
210
+ def _one_row_per_accession(cand: pd.DataFrame) -> pd.DataFrame:
211
+ """Reduce the candidate evidence to one row per UniProt accession.
212
+
213
+ Several gene IDs can map to one accession -- read-through loci, paralogous
214
+ symbols sharing a product -- and each carries its own ``log2fc`` and
215
+ ``padj``, which feed the evidence component of the composite score. The
216
+ previous ``drop_duplicates("uniprot_id")`` kept whichever row happened to
217
+ come first, so the evidence joined to a binder depended on the row order of
218
+ an upstream table: a different answer from the same data.
219
+
220
+ The strongest evidence for the protein wins instead: smallest ``padj``, then
221
+ largest ``|log2fc|``, then the symbol, so the result is the same whatever
222
+ order the rows arrive in. A collision is logged, because an accession with
223
+ two disagreeing rows is something the reader should be able to see.
224
+
225
+ No committed cohort has ever contained one (4,665 candidate rows across the
226
+ eleven real runs, zero duplicated accessions), which is why the arbitrary
227
+ tie-break went unnoticed; that is a reason to make it deterministic, not a
228
+ reason to leave it.
229
+ """
230
+ accessions = cand["uniprot_id"]
231
+ duplicated = accessions.notna() & accessions.duplicated(keep=False)
232
+ if not duplicated.any():
233
+ return cand.drop_duplicates("uniprot_id")
234
+
235
+ for accession, group in cand[duplicated].groupby("uniprot_id", sort=True):
236
+ LOG.warning(
237
+ "%d candidate rows share accession %s (%s); keeping the most "
238
+ "significant one for ranking evidence",
239
+ len(group),
240
+ accession,
241
+ ", ".join(sorted(str(v) for v in group.get("symbol", []))) or "no symbols",
242
+ )
243
+
244
+ order = cand.copy()
245
+ order["_padj"] = order["padj"].astype(float) if "padj" in order.columns else 1.0
246
+ order["_absfc"] = -order["log2fc"].abs().astype(float) if "log2fc" in order.columns else 0.0
247
+ order["_symbol"] = order["symbol"].astype(str) if "symbol" in order.columns else ""
248
+ # NaN padj sorts last under na_position, so a row with no statistic never
249
+ # displaces one that has it.
250
+ order = order.sort_values(["_padj", "_absfc", "_symbol"], na_position="last", kind="mergesort")
251
+ return (
252
+ order.drop_duplicates("uniprot_id")
253
+ .drop(columns=["_padj", "_absfc", "_symbol"])
254
+ .loc[:, cand.columns]
255
+ )
256
+
257
+
258
+ def _evidence_score(df: pd.DataFrame) -> pd.Series:
259
+ """log2FC on an absolute scale, cut by a vital-tissue specificity penalty.
260
+
261
+ ``n_safety_events`` counts the normal tissues where the target is expressed
262
+ above the configured safety ceiling (see ``bindsight.targets.gtex``). Each
263
+ event divides the evidence score down, so a strongly over-expressed target
264
+ that is also present in vital tissue cannot outrank a comparably
265
+ over-expressed one that is tumour-restricted. A target with no recorded
266
+ events is unpenalised.
267
+
268
+ Returns NaN where ``log2fc`` is absent, matching the other components: a
269
+ missing metric is excluded from the composite rather than scored as zero.
270
+ """
271
+ if "log2fc" not in df.columns:
272
+ return pd.Series([float("nan")] * len(df), index=df.index)
273
+ score = _fold_change_score(df["log2fc"])
274
+ if "n_safety_events" in df.columns:
275
+ events = pd.to_numeric(df["n_safety_events"], errors="coerce").fillna(0.0).clip(lower=0.0)
276
+ score = score * (1.0 / (1.0 + events))
277
+ return score
278
+
279
+
280
+ def _structure_score(df: pd.DataFrame) -> pd.Series:
281
+ """Mean of the available structure-confidence signals, all oriented so higher is better.
282
+
283
+ Carries iPTM, pTM and binder pLDDT (confidence, higher better) alongside
284
+ inverted pAE-interaction and RMSD (error, lower better). pTM and pLDDT live
285
+ here rather than in the affinity component because they measure how confident
286
+ the predictor is in the fold, not how tightly the binder binds.
287
+ """
288
+ parts: list[pd.Series] = []
289
+ if "iptm" in df.columns:
290
+ parts.append(_minmax(df["iptm"]))
291
+ if "ptm" in df.columns:
292
+ parts.append(_minmax(df["ptm"]))
293
+ if "plddt_binder" in df.columns:
294
+ parts.append(_minmax(df["plddt_binder"]))
295
+ if "pae_interaction" in df.columns:
296
+ parts.append(_minmax(df["pae_interaction"], invert=True))
297
+ if "rmsd_to_designed" in df.columns:
298
+ parts.append(_minmax(df["rmsd_to_designed"], invert=True))
299
+ if not parts:
300
+ return pd.Series([float("nan")] * len(df), index=df.index)
301
+ stacked = pd.concat(parts, axis=1)
302
+ return stacked.mean(axis=1, skipna=True)
303
+
304
+
305
+ def _affinity_score(df: pd.DataFrame) -> pd.Series:
306
+ """Combined predicted affinity + binary binder probability, higher = better.
307
+
308
+ ``affinity_pred_value`` is a log(IC50)-like quantity: **lower is a stronger
309
+ binder** (-8.0 beats -6.0), so it is inverted on the way in. Getting this
310
+ backwards silently promotes the weakest designs, which is why it is asserted
311
+ numerically in ``tests/test_rank.py`` rather than only by ordering.
312
+ """
313
+ parts: list[pd.Series] = []
314
+ if "affinity_pred_value" in df.columns:
315
+ parts.append(_minmax(df["affinity_pred_value"], invert=True))
316
+ if "affinity_probability_binary" in df.columns:
317
+ # Already in [0, 1]
318
+ parts.append(df["affinity_probability_binary"].astype(float))
319
+ if not parts:
320
+ return pd.Series([float("nan")] * len(df), index=df.index)
321
+ stacked = pd.concat(parts, axis=1)
322
+ return stacked.mean(axis=1, skipna=True)
323
+
324
+
325
+ def rank_run(
326
+ run_dir: Path | str,
327
+ *,
328
+ weights: RankWeights | None = None,
329
+ ) -> Path:
330
+ """Read a finished run, rank validated binders, write ``rank/ranking.parquet``.
331
+
332
+ Returns the output path. Raises FileNotFoundError if validation hasn't
333
+ been run.
334
+ """
335
+ run = Path(run_dir)
336
+ validated_path = run / "validate" / "validated.parquet"
337
+ candidates_path = run / "targets" / "candidates.parquet"
338
+ if not validated_path.exists() or validated_path.stat().st_size == 0:
339
+ raise FileNotFoundError(
340
+ f"no validation output at {validated_path}; run `bindsight validate <run_dir>` first."
341
+ )
342
+
343
+ validated = pd.read_parquet(validated_path)
344
+ candidates = (
345
+ pd.read_parquet(candidates_path)
346
+ if candidates_path.exists() and candidates_path.stat().st_size > 0
347
+ else None
348
+ )
349
+
350
+ ranked = rank_validated(validated, candidates, weights=weights)
351
+ out = run / "rank" / "ranking.parquet"
352
+ out.parent.mkdir(parents=True, exist_ok=True)
353
+ ranked.to_parquet(out, index=False)
354
+ LOG.info("wrote %s (%d rows)", out, len(ranked))
355
+ return out
@@ -0,0 +1,39 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Reporting: paper-style HTML + a served interface, both backed by the same data.
4
+
5
+ ``render_run`` is resolved lazily. Importing it eagerly pulled in
6
+ :mod:`bindsight.report.html`, and therefore pandas and jinja2, for anyone who
7
+ touched *anything* under this package — including :mod:`bindsight.report.theme`
8
+ and :mod:`bindsight.report.showcase`, both of which are deliberately
9
+ stdlib-only at module scope so they stay usable in minimal environments. The
10
+ documentation build is exactly such an environment.
11
+
12
+ The lazy hook must raise ``AttributeError`` (never ``ImportError``) for unknown
13
+ names: ``from bindsight.report import showcase`` consults ``__getattr__`` before
14
+ falling back to the submodule import machinery, so swallowing the error here
15
+ would break every ``from bindsight.report import <submodule>`` in the codebase.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ if TYPE_CHECKING: # pragma: no cover - typing only
23
+ from bindsight.report.html import render_run
24
+
25
+ __all__ = ["render_run"]
26
+
27
+
28
+ def __getattr__(name: str) -> Any:
29
+ """Resolve ``render_run`` on first access (PEP 562)."""
30
+ if name == "render_run":
31
+ from bindsight.report.html import render_run as _render_run
32
+
33
+ return _render_run
34
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
35
+
36
+
37
+ def __dir__() -> list[str]:
38
+ """Keep ``dir()`` honest about the lazily-exported name."""
39
+ return sorted({*globals(), *__all__})
@@ -0,0 +1,100 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """How bindsight writes a number down.
4
+
5
+ Shared by both surfaces on purpose. These lived inside the web app, and the
6
+ standalone report kept its own ``f"{v:.3g}"`` -- so the same ``padj`` from the
7
+ same run printed as ``<1e-300`` in the browser and as ``0`` in the file a
8
+ collaborator opened from an email. A design system that shares a stylesheet and
9
+ not its rules only makes the disagreement harder to spot.
10
+
11
+ Three of these are claims rather than cosmetics:
12
+
13
+ * **Absence is an em dash, never a zero.** A crisp ``0`` in metric chrome reads
14
+ as a measurement, and a crashed run then looks like one that found nothing.
15
+ * **A p-value never prints as zero.** CA9's ``padj`` is literally ``0.0`` in the
16
+ artifact -- below floating-point resolution, not measured as zero.
17
+ * **A confidence level is read, never assumed.** An interval labelled with a
18
+ level nothing computed states two things from two different places.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ #: How a number with nothing behind it is written. Never ``0``: four zeros in
26
+ #: metric chrome read as a measurement, and a broken run then looks like a run
27
+ #: that found nothing.
28
+ ABSENT = "—"
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Presentation helpers
33
+ # ---------------------------------------------------------------------------
34
+ def fmt(value: Any, digits: int = 2) -> str:
35
+ """A number, or :data:`ABSENT` if there is not one."""
36
+ if value is None:
37
+ return ABSENT
38
+ try:
39
+ number = float(value)
40
+ except (TypeError, ValueError):
41
+ return ABSENT
42
+ if number != number: # NaN
43
+ return ABSENT
44
+ return f"{number:.{digits}f}"
45
+
46
+
47
+ def fmt_int(value: Any) -> str:
48
+ """An integer for the screen, or the em dash when there is not one.
49
+
50
+ Absence renders as ``—``. It never renders as ``0``: a crisp zero in
51
+ metric chrome reads as a measurement, which is how a run that crashed
52
+ comes to look like a run that found nothing.
53
+ """
54
+ if value is None:
55
+ return ABSENT
56
+ try:
57
+ return f"{int(value):,}"
58
+ except (TypeError, ValueError):
59
+ return ABSENT
60
+
61
+
62
+ def fmt_p(value: Any) -> str:
63
+ """A p-value that never prints as zero.
64
+
65
+ ``%.2e`` rendered CA9's ``padj`` -- literally 0.0 in the artifact, below
66
+ floating-point resolution -- as ``0.00e+00``. A p of zero is not a p.
67
+ """
68
+ if value is None:
69
+ return ABSENT
70
+ try:
71
+ number = float(value)
72
+ except (TypeError, ValueError):
73
+ return ABSENT
74
+ if number != number:
75
+ return ABSENT
76
+ if number <= 0:
77
+ return "<1e-300"
78
+ # Scientific below 1e-3, not 1e-4: the study's own permutation p is
79
+ # 3.97e-04, and fixed notation rendered that as "0.0004" -- losing the two
80
+ # significant figures that distinguish it from the floor it sits on.
81
+ if number < 1e-3:
82
+ return f"{number:.2e}"
83
+ return f"{number:.4f}"
84
+
85
+
86
+ def interval(low: Any, high: Any, *, digits: int = 3, confidence: Any = None) -> str:
87
+ """``95% CI 0.188–0.513``, or :data:`ABSENT`.
88
+
89
+ The confidence level is read, never assumed. An interval labelled with a
90
+ level nothing computed states two things from two different places.
91
+ """
92
+ if low is None or high is None:
93
+ return ABSENT
94
+ label = ""
95
+ if confidence is not None:
96
+ try:
97
+ label = f"{float(confidence) * 100:g}% "
98
+ except (TypeError, ValueError):
99
+ label = ""
100
+ return f"{label}CI {fmt(low, digits)}–{fmt(high, digits)}"