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,607 @@
1
+ # SPDX-FileCopyrightText: 2026 Mikhaeel Atef Rizk Wahba
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ """Render the rediscovery study as Markdown a reviewer can check.
4
+
5
+ Two rules govern what this writes, and both exist because the previous results
6
+ page broke them.
7
+
8
+ **Every number carries what it was computed over.** A rank without its shortlist
9
+ size, a rate without its numerator and denominator, a p-value without its
10
+ reference set: each of those is a number a reader cannot check and cannot
11
+ disagree with. The old page reported "ERBB2 at rank 4" and "recall@5 = 33%"
12
+ without ever printing the size of the shortlist or the fact that the denominator
13
+ was three.
14
+
15
+ **Every absence says which kind of absence it is.** An antigen the surfaceome
16
+ reference does not contain, one a stated filter excluded, and one the ranker
17
+ placed low are three different findings. Reporting them as a single miss tells a
18
+ reader nothing about what to fix, so each row states its class and its
19
+ counterfactual rank.
20
+
21
+ The page is generated, never hand-edited, so it cannot drift from the artifacts.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Mapping
27
+ from typing import Any
28
+
29
+ from bindsight.benchmark import outcomes as O
30
+ from bindsight.benchmark import panel as P
31
+ from bindsight.report.format import fmt_p
32
+
33
+ __all__ = ["render_markdown"]
34
+
35
+ _CLASS_TITLE = {
36
+ O.RANKED: "Reached the shortlist",
37
+ O.GATED_OUT: "Measured, then excluded by a stated filter",
38
+ O.NOT_REACHABLE: "Outside the instrument's reach",
39
+ O.INFRASTRUCTURE: "Invalid — a lookup errored or never ran",
40
+ }
41
+
42
+ _CLASS_NOTE = {
43
+ O.RANKED: (
44
+ "These entered the candidate shortlist. The rank is only interpretable "
45
+ "beside the shortlist size, so both are printed."
46
+ ),
47
+ O.GATED_OUT: (
48
+ "These were measured and then excluded by a named filter. The "
49
+ "counterfactual rank says whether the filter or the ranking was "
50
+ "responsible: a low counterfactual rank means a gate excluded an antigen "
51
+ "the ranking would have placed well."
52
+ ),
53
+ O.NOT_REACHABLE: (
54
+ "These are absent from the surfaceome reference, so no expression level "
55
+ "could have surfaced them. They are instrument-coverage failures, not "
56
+ "ranking failures, and are excluded from every rate. The fix is to extend "
57
+ "the reference."
58
+ ),
59
+ O.INFRASTRUCTURE: (
60
+ "A lookup that errored or never ran is not a scientific negative. Any pair "
61
+ "here invalidates itself and must be re-run before publication."
62
+ ),
63
+ }
64
+
65
+
66
+ def _interval_phrase(interval: Mapping[str, Any] | None) -> str:
67
+ """Render an interval as ``(95% CI a–b, n antigens)``, or empty when absent.
68
+
69
+ Written to degrade rather than fail: an artifact produced before the study
70
+ recorded these fields still renders, it simply renders the bare mean it has.
71
+ A missing interval is a gap in the artifact, not a reason for the report to
72
+ stop building.
73
+ """
74
+ if not interval:
75
+ return ""
76
+ low, high = interval.get("low"), interval.get("high")
77
+ n = interval.get("n_clusters")
78
+ if low is None or high is None:
79
+ return ""
80
+ inside = f"{_confidence_label(interval)}CI {_fmt(low, 3)}–{_fmt(high, 3)}"
81
+ if n:
82
+ inside += f", {n} antigen{'s' if int(n) != 1 else ''}"
83
+ return f" ({inside})"
84
+
85
+
86
+ def _confidence_label(interval: Mapping[str, Any]) -> str:
87
+ """``"95% "`` when the artifact recorded a level, ``""`` when it did not.
88
+
89
+ Both callers used ``interval.get("confidence") or 0.95``, which prints
90
+ "95% CI" over an interval whose level nothing had established -- the second
91
+ of them directly under a comment saying that doing so "would misstate it".
92
+ An interval is not publishable without its level, so the level is either
93
+ read from the artifact or not claimed. It is never assumed.
94
+
95
+ ``or`` was also wrong for a recorded ``0``: falsy, so it silently became
96
+ 0.95. Presence is tested, not truthiness.
97
+ """
98
+ raw = interval.get("confidence")
99
+ if raw is None:
100
+ return ""
101
+ return f"{float(raw) * 100:g}% "
102
+
103
+
104
+ def _paired_phrase(paired: Mapping[str, Any] | None) -> str:
105
+ """The within-antigen contrast, which is the comparison the sentence makes.
106
+
107
+ The two means share antigens, so their difference is not the difference of
108
+ two independent samples. Pairing inside each antigen is; this is the number
109
+ a reader should weigh.
110
+ """
111
+ if not paired:
112
+ return ""
113
+ point, low, high = paired.get("point"), paired.get("low"), paired.get("high")
114
+ if point is None or low is None or high is None:
115
+ return ""
116
+ n = paired.get("n_clusters")
117
+ spans_zero = float(low) <= 0.0 <= float(high)
118
+ tail = ", an interval that includes zero" if spans_zero else ", an interval that excludes zero"
119
+ # Read, not assumed: the artifact records the confidence level, and printing
120
+ # "95%" over a block computed at another level would misstate it.
121
+ return (
122
+ f" — a within-antigen difference of **{_fmt(point, 3)}** "
123
+ f"({_confidence_label(paired)}CI {_fmt(low, 3)}–{_fmt(high, 3)} over {n} antigen"
124
+ f"{'s' if n and int(n) != 1 else ''}{tail})"
125
+ )
126
+
127
+
128
+ def _fmt_p(value: Any) -> str:
129
+ """A p-value, under the rule the other two surfaces already apply.
130
+
131
+ ``_fmt`` guards its scientific-notation branch with ``value != 0``, so a
132
+ padj of literally 0.0 -- below floating-point resolution, which is what the
133
+ strongest result in the panel carries -- fell through to ``0.000``. In a
134
+ column whose other entries read ``8.39e-58``, that makes the most
135
+ significant antigen look like the least. ``fmt_p`` is the same function the
136
+ served interface and the standalone HTML report use.
137
+ """
138
+ return fmt_p(value)
139
+
140
+
141
+ def _fmt(value: Any, places: int = 3) -> str:
142
+ """Numbers to fixed precision, absences as an explicit dash."""
143
+ if value is None:
144
+ return "—"
145
+ if isinstance(value, bool):
146
+ return "yes" if value else "no"
147
+ if isinstance(value, float):
148
+ if value != value: # NaN
149
+ return "—"
150
+ if value != 0 and (abs(value) < 1e-3 or abs(value) >= 1e5):
151
+ return f"{value:.2e}"
152
+ return f"{value:.{places}f}"
153
+ return str(value)
154
+
155
+
156
+ def _interval(block: dict[str, Any] | None) -> str:
157
+ """A rate as point estimate, interval and the counts behind it."""
158
+ if not block:
159
+ return "—"
160
+ # `_confidence_label` exists for this and says why: an interval is not
161
+ # publishable without its level, so the level is read from the artifact or
162
+ # not claimed. `_interval_phrase` already used it; this renderer -- the
163
+ # headline cascade and the primary interval -- printed "95% CI" over
164
+ # whatever level the artifact happened to record.
165
+ return (
166
+ f"{block['numerator']}/{block['denominator']} = {block['point']:.3f} "
167
+ f"({_confidence_label(block)}CI {block['low']:.3f}–{block['high']:.3f})"
168
+ )
169
+
170
+
171
+ def render_markdown(summary: dict[str, Any]) -> str:
172
+ """Render the study summary produced by :func:`bindsight.benchmark.study.summarise`."""
173
+ design = summary.get("design", {})
174
+ lines: list[str] = [
175
+ "# bindsight rediscovery study — results",
176
+ "",
177
+ "Does bindsight's expression-based discovery resurface clinically validated "
178
+ "cell-surface antigens from real TCGA RNA-seq?",
179
+ "",
180
+ "## How this study is built",
181
+ "",
182
+ f"- **Cohorts:** {design.get('cohorts', '—')}.",
183
+ f"- **Contrast:** `{design.get('contrast', '—')}`.",
184
+ "- **Denominators are pre-registered**, not chosen after seeing the data.",
185
+ "- **Every rate carries an interval.** At these panel sizes the interval, "
186
+ "not the point estimate, is the finding.",
187
+ "",
188
+ "### Why cohorts are not stratified",
189
+ "",
190
+ str(design.get("admissible_stratifier_rule", "")),
191
+ "",
192
+ "### Antigens the instrument cannot reach",
193
+ "",
194
+ str(design.get("unreachable_note", "")),
195
+ "",
196
+ ]
197
+
198
+ cascade = summary.get("recall_cascade", {})
199
+ if cascade:
200
+ # The cutoff the artifact actually reported. `study.py:758` sets it to 20
201
+ # only when 20 is among the configured cutoffs and otherwise to the last
202
+ # one, so a literal "Recall@20" header can name a column it is not.
203
+ headline_k = summary.get("headline_k", 20)
204
+ lines += [
205
+ "## Headline",
206
+ "",
207
+ "Three nested denominators. Each is a different question, and merging "
208
+ "them would answer none of them.",
209
+ "",
210
+ f"| Denominator | What it asks | Recall@{headline_k} |",
211
+ "|---|---|---|",
212
+ ]
213
+ # `all` means every pair in the primary tier, NOT every scored pair: the
214
+ # sensitivity table below counts 22 where this counts 17. Printing "every
215
+ # scored pair" beside 1/17 invited the reader to conclude five pairs had
216
+ # been dropped without explanation. The tier is now named in the label.
217
+ tiers = summary.get("design", {}).get("tiers_in_primary_denominator") or []
218
+ tier_label = "+".join(str(t) for t in tiers) if tiers else "primary tier"
219
+ questions = {
220
+ "all": f"Of every scored **{tier_label}**-tier pair, how many were surfaced?",
221
+ "reachable": "Of those the instrument could see at all?",
222
+ "gate_passed": "Of those that reached the shortlist? This alone measures the ranking.",
223
+ }
224
+ labels = {
225
+ "all": f"all ({tier_label})",
226
+ "reachable": "reachable",
227
+ "gate_passed": "gate_passed",
228
+ }
229
+ for name in ("all", "reachable", "gate_passed"):
230
+ block = cascade.get(name, {})
231
+ lines.append(
232
+ f"| `{labels[name]}` | {questions[name]} | {_interval(block.get('wilson'))} |"
233
+ )
234
+ lines.append("")
235
+
236
+ # One cutoff cannot be compared across studies whose shortlists differ in
237
+ # size. The shortlist here is roughly 285 candidates; before the surfaceome
238
+ # pre-filter it was roughly 40, so "within the top 20" quietly became a far
239
+ # harder question. Publishing the range and the shortlist size is what lets
240
+ # a reader see that instead of having to infer it.
241
+ cutoffs = sorted(
242
+ {int(k.removeprefix("recall@")) for b in cascade.values() for k in b.get("at_k", {})}
243
+ )
244
+ if cutoffs:
245
+ lines += [
246
+ "### Recall across cutoffs",
247
+ "",
248
+ "An absolute cutoff is only comparable between cohorts whose "
249
+ "shortlists are of similar size, so the median shortlist is printed "
250
+ "beside each row.",
251
+ "",
252
+ "| Denominator | Median shortlist | " + " | ".join(f"@{k}" for k in cutoffs) + " |",
253
+ "|---|--:|" + "--:|" * len(cutoffs),
254
+ ]
255
+ for name in ("all", "reachable", "gate_passed"):
256
+ block = cascade.get(name, {})
257
+ at_k = block.get("at_k", {})
258
+ if not at_k:
259
+ continue
260
+ name = labels[name]
261
+ cells = []
262
+ for k in cutoffs:
263
+ w = at_k.get(f"recall@{k}", {}).get("wilson")
264
+ cells.append(f"{w['numerator']}/{w['denominator']}" if w else "—")
265
+ lines.append(
266
+ f"| `{name}` | {block.get('median_shortlist_size', '—')} | "
267
+ + " | ".join(cells)
268
+ + " |"
269
+ )
270
+ lines.append("")
271
+
272
+ sensitivity = summary.get("tier_sensitivity")
273
+ if sensitivity:
274
+ lines += [
275
+ "### Sensitivity to the regulatory tier",
276
+ "",
277
+ str(sensitivity.get("description", "")),
278
+ "",
279
+ "| Cutoff | Every scored pair |",
280
+ "|---|--:|",
281
+ ]
282
+ for k, block in sensitivity.get("at_k", {}).items():
283
+ lines.append(f"| {k} | {block['numerator']}/{block['denominator']} |")
284
+ lines.append("")
285
+
286
+ primary = summary.get("primary_interval")
287
+ if primary:
288
+ lines += [
289
+ "### Interval over independent antigens",
290
+ "",
291
+ str(primary.get("description", "")),
292
+ "",
293
+ f"- Wilson: {_interval(primary.get('wilson'))}",
294
+ f"- Clopper-Pearson: {_interval(primary.get('clopper_pearson'))}",
295
+ f"- Distinct antigens: {primary.get('n_distinct_antigens')}",
296
+ "",
297
+ ]
298
+
299
+ bootstrap = summary.get("cluster_bootstrap")
300
+ if bootstrap:
301
+ lines += [
302
+ f"- Cluster bootstrap over antigens: {_interval(bootstrap)}",
303
+ f" `{bootstrap.get('method', '')}`",
304
+ "",
305
+ ]
306
+
307
+ infra = summary.get("infrastructure_failures", {})
308
+ if infra:
309
+ lines += [
310
+ "### Invalid pairs",
311
+ "",
312
+ f"**{infra.get('n', 0)}**. {infra.get('note', '')}",
313
+ "",
314
+ ]
315
+ if infra.get("pairs"):
316
+ lines += [f"Affected: {', '.join(infra['pairs'])}", ""]
317
+
318
+ lines += _render_nulls(summary)
319
+ # The headline antigen's own caveat, beside the table that ranks it first.
320
+ # The artifact has carried this text since the study ran and no surface
321
+ # rendered it: that the girentuximab phase 3 failed its primary endpoint, so
322
+ # the live asset is an imaging agent rather than a therapeutic, and that CA9
323
+ # is absent from SURFY. A reader meeting "rank 1 of 291" without it is
324
+ # reading the strongest number in the study with its qualification removed.
325
+ # Derived the same way bindsight.report.showcase derives it -- the
326
+ # best-ranked surfaced pair -- because the artifact records no top-level
327
+ # "headline" key and reading one would silently render nothing.
328
+ _ranked = [p for p in (summary.get("pairs") or []) if isinstance(p.get("rank"), int)]
329
+ headline: dict[str, Any] = min(_ranked, key=lambda p: p["rank"], default={}) or {}
330
+ if headline.get("note"):
331
+ lines += [
332
+ f"> **What qualifies {headline.get('symbol', 'the top-ranked antigen')}.** "
333
+ f"{headline['note']}",
334
+ "",
335
+ ]
336
+
337
+ lines += _render_pairs(summary.get("pairs", []))
338
+ lines += _render_set_sizes(summary.get("set_sizes_by_cohort", {}))
339
+ lines += _render_excluded()
340
+
341
+ not_run = summary.get("cohorts_not_yet_run") or []
342
+ if not_run:
343
+ lines += [
344
+ "## Cohorts not yet run",
345
+ "",
346
+ "A cohort that has not run is not a cohort that found nothing, so these "
347
+ "are named rather than counted as misses.",
348
+ "",
349
+ ", ".join(sorted(not_run)),
350
+ "",
351
+ ]
352
+
353
+ lines += [
354
+ "## Reproduce",
355
+ "",
356
+ "```bash",
357
+ 'pip install -e ".[discover,report]"',
358
+ "python benchmarks/run_study.py --list",
359
+ "python benchmarks/run_study.py --all --cpus 2",
360
+ "```",
361
+ "",
362
+ "`--score-only` re-derives every number above from the run directories "
363
+ "already on disk, without re-running any differential expression.",
364
+ "",
365
+ ]
366
+ return "\n".join(lines) + "\n"
367
+
368
+
369
+ def _render_nulls(summary: dict[str, Any]) -> list[str]:
370
+ """The null models, which the human-readable report carried none of.
371
+
372
+ Until now this page published three interval estimators and no null-model
373
+ p-value of any kind. The module's own docstring calls the decoy null "the
374
+ primary null" and it had never run; the only null that did run, the uniform
375
+ rank, is the one the module calls the weakest, and its output never left
376
+ results.json.
377
+ """
378
+ pairs = [p for p in summary.get("pairs", []) if p.get("p_decoy") is not None]
379
+ spec = summary.get("specificity_null")
380
+ if not pairs and not spec:
381
+ return []
382
+
383
+ lines = ["## Null models", ""]
384
+
385
+ if pairs:
386
+ exact = sum(1 for p in pairs if p.get("decoy_exact"))
387
+ lines += [
388
+ "### Decoy null — the primary one",
389
+ "",
390
+ "Each antigen is compared against the genes matched to it on abundance "
391
+ "and dispersion quintile, drawn from the same eligible surfaceome and "
392
+ "ranked by the same counterfactual score. The question it answers: "
393
+ "would a gene that merely *looks* like this antigen have ranked as "
394
+ "well?",
395
+ "",
396
+ "Taken over the **counterfactual** rank rather than the shortlist "
397
+ "rank, so no gate has to be matched and the tail is computed exactly "
398
+ "rather than sampled — "
399
+ f"{exact} of {len(pairs)} pairs used their whole stratum.",
400
+ "",
401
+ "**`floor` is the smallest p the stratum could have produced.** A p "
402
+ "equal to its floor means no decoy beat the antigen and the stratum "
403
+ "had no finer resolution to offer; it is not the same statement as a "
404
+ "p of that size drawn from a large pool.",
405
+ "",
406
+ "| antigen | cohort | counterfactual rank | p | BH | floor | decoys |",
407
+ "|---|---|--:|--:|--:|--:|--:|",
408
+ ]
409
+ for pair in sorted(pairs, key=lambda p: float(p["p_decoy"])):
410
+ lines.append(
411
+ f"| **{pair['symbol']}** | {str(pair['project']).removeprefix('TCGA-')} "
412
+ f"| {pair.get('counterfactual_rank')} "
413
+ f"| {_fmt_p(pair['p_decoy'])} "
414
+ f"| {_fmt_p(pair.get('p_decoy_bh'))} "
415
+ f"| {_fmt_p(pair.get('p_decoy_floor'))} "
416
+ f"| {pair.get('decoy_pool_size')} |"
417
+ )
418
+ nominal = [p for p in pairs if float(p["p_decoy"]) < 0.05]
419
+ survivors = [
420
+ p for p in pairs if isinstance(p.get("p_decoy_bh"), float) and p["p_decoy_bh"] < 0.05
421
+ ]
422
+ lines += [
423
+ "",
424
+ f"**{len(nominal)} of {len(pairs)} pairs are nominally significant at 0.05"
425
+ + (f" ({', '.join(sorted(p['symbol'] for p in nominal))})" if nominal else "")
426
+ + f", and {len(survivors)} survive Benjamini-Hochberg across the panel.**",
427
+ "",
428
+ ]
429
+ if nominal and not survivors:
430
+ lines += [
431
+ "That is the finding, and it is a negative one: against background "
432
+ "matched on abundance and dispersion, no antigen in this panel is "
433
+ "distinguishable once the panel is corrected for its own size. "
434
+ f"Reporting the {len(nominal)} nominal "
435
+ f"hit{'s' if len(nominal) != 1 else ''} without the correction would "
436
+ "be the error this column exists to prevent.",
437
+ "",
438
+ ]
439
+
440
+ if spec:
441
+ lines += [
442
+ "### Indication-specificity null",
443
+ "",
444
+ str(spec.get("description", "")),
445
+ "",
446
+ f"- Observed mean standing: **{_fmt(spec.get('observed'), 3)}** "
447
+ "(1.0 is the top of the eligible surfaceome, 0.0 the bottom)",
448
+ f"- p = **{_fmt_p(spec.get('p_value'))}** over "
449
+ f"{spec.get('n_permutations')} "
450
+ + ("enumerated" if spec.get("exact") else "sampled")
451
+ + " permutations"
452
+ + (
453
+ " — the floor for this many, so it is as extreme as this "
454
+ "permutation count can show rather than vanishingly small"
455
+ if spec.get("p_value_floor") is not None
456
+ and spec.get("p_value") is not None
457
+ and spec["p_value"] <= spec["p_value_floor"] * 1.000001
458
+ else ""
459
+ ),
460
+ f"- Computed over {spec.get('n_antigens')} antigens "
461
+ f"and {spec.get('n_cohorts')} cohorts: "
462
+ f"{', '.join(spec.get('antigens', []))}",
463
+ "",
464
+ ]
465
+ excluded_multi = spec.get("excluded_multi_indication") or []
466
+ excluded_missing = spec.get("excluded_not_scored_everywhere") or []
467
+ if excluded_multi:
468
+ lines += [
469
+ f"Excluded, several indications each: {', '.join(excluded_multi)}. "
470
+ "The test assigns one cohort per antigen, so an antigen with four "
471
+ "indications has no single 'own' cohort to hold fixed.",
472
+ "",
473
+ ]
474
+ calib = summary.get("null_calibration")
475
+ if calib:
476
+ own = calib.get("mean_standing_in_own_indication")
477
+ off = calib.get("mean_standing_off_indication")
478
+ off_ci = calib.get("off_indication_interval") or {}
479
+ own_ci = calib.get("own_indication_interval") or {}
480
+ paired = calib.get("paired_difference") or {}
481
+ # The interval's own point, not the flat mean beside it. The flat
482
+ # mean counts an antigen once per cohort and the interval counts it
483
+ # once, so pairing them printed 0.738 with bounds around 0.765 --
484
+ # a point estimate and an interval for two different estimators.
485
+ off = off_ci.get("point", off)
486
+ own = own_ci.get("point", own)
487
+ lines += [
488
+ "**Calibration.** "
489
+ + ", ".join(str(x).removeprefix("TCGA-") for x in calib.get("projects", []))
490
+ + " carry no panel antigen and were run to show what no signal "
491
+ "looks like on this scale. Panel antigens land at a mean standing "
492
+ f"of **{_fmt(off, 3)}**{_interval_phrase(off_ci)} there — the middle "
493
+ "of the eligible surfaceome — against "
494
+ f"**{_fmt(own, 3)}**{_interval_phrase(own_ci)} in their own "
495
+ f"indication{_paired_phrase(paired)}. "
496
+ f"Neither cohort contributes a scored pair "
497
+ f"({calib.get('n_scored_pairs')}), because inventing an "
498
+ "expectation for a cohort chosen for having none is the error "
499
+ "they exist to avoid.",
500
+ "",
501
+ ]
502
+
503
+ if excluded_missing:
504
+ # Derived, not asserted. This sentence used to end "This list
505
+ # includes CA9, the strongest single signal in the panel, so the
506
+ # specificity result is reached without it" — a claim about the
507
+ # list's contents written as a literal. When the eligible surfaceome
508
+ # was corrected, CA9 stopped being excluded and the sentence went on
509
+ # saying it was. A report that describes its own table has to read
510
+ # the table.
511
+ note = (
512
+ f"Excluded, not scored in every cohort: {', '.join(excluded_missing)}. "
513
+ "A complete matrix is required, or the observed statistic and the "
514
+ "permuted one would be built from different sets of cohorts."
515
+ )
516
+ if "CA9" in excluded_missing:
517
+ note += (
518
+ " That list includes CA9, the strongest single signal in the "
519
+ "panel, so the specificity result is reached without it."
520
+ )
521
+ lines += [note, ""]
522
+
523
+ return lines
524
+
525
+
526
+ def _render_pairs(pairs: list[dict[str, Any]]) -> list[str]:
527
+ """One section per outcome class, so absences are never merged."""
528
+ if not pairs:
529
+ return []
530
+ lines = ["## Results by outcome class", ""]
531
+ by_class: dict[str, list[dict[str, Any]]] = {}
532
+ for pair in pairs:
533
+ by_class.setdefault(str(pair.get("outcome_class")), []).append(pair)
534
+
535
+ for cls in (O.RANKED, O.GATED_OUT, O.NOT_REACHABLE, O.INFRASTRUCTURE):
536
+ rows = by_class.get(cls, [])
537
+ if not rows:
538
+ continue
539
+ lines += [
540
+ f"### {_CLASS_TITLE[cls]} ({len(rows)})",
541
+ "",
542
+ _CLASS_NOTE[cls],
543
+ "",
544
+ "| Cohort | Antigen | Agent | Tier | log2FC | padj | Rank / shortlist | "
545
+ "Counterfactual rank / eligible | Direction | Why |",
546
+ "|---|---|---|---|--:|--:|--:|--:|---|---|",
547
+ ]
548
+ for r in sorted(rows, key=lambda x: (str(x.get("project")), str(x.get("symbol")))):
549
+ rank = f"{r['rank']} / {r['shortlist_size']}" if r.get("rank") is not None else "—"
550
+ cf = (
551
+ f"{r['counterfactual_rank']} / {r['n_eligible']}"
552
+ if r.get("counterfactual_rank") is not None
553
+ else "—"
554
+ )
555
+ lines.append(
556
+ f"| {r.get('project')} | **{r.get('symbol')}** ({r.get('uniprot')}) "
557
+ f"| {r.get('agent')} | {r.get('tier')} | {_fmt(r.get('log2fc'), 2)} "
558
+ f"| {_fmt_p(r.get('padj'))} | {rank} | {cf} | {r.get('direction', '—')} "
559
+ f"| {r.get('reason', '')} |"
560
+ )
561
+ lines.append("")
562
+ return lines
563
+
564
+
565
+ def _render_set_sizes(sizes: dict[str, dict[str, int]]) -> list[str]:
566
+ """The sets every rank and rate above was computed over."""
567
+ if not sizes:
568
+ return []
569
+ lines = [
570
+ "## Set sizes",
571
+ "",
572
+ "A rank means nothing without the size of the set it was taken within, so "
573
+ "those sizes are published rather than left to be inferred.",
574
+ "",
575
+ "| Cohort | Genes tested | Significant | Eligible surfaceome | Candidate shortlist |",
576
+ "|---|--:|--:|--:|--:|",
577
+ ]
578
+ for project in sorted(sizes):
579
+ s = sizes[project]
580
+ lines.append(
581
+ f"| {project} | {s.get('n_genes_tested', '—')} | {s.get('n_significant', '—')} "
582
+ f"| {s.get('n_eligible_surfaceome', '—')} | {s.get('n_candidates', '—')} |"
583
+ )
584
+ lines.append("")
585
+ return lines
586
+
587
+
588
+ def _render_excluded() -> list[str]:
589
+ """Pairs published for transparency but excluded from every denominator."""
590
+ lines = [
591
+ "## Published but excluded from every denominator",
592
+ "",
593
+ "These are reported so a reader can see what was left out and why. A rate "
594
+ "computed over them would be meaningless.",
595
+ "",
596
+ "| Cohort | Antigen | Reason |",
597
+ "|---|---|---|",
598
+ ]
599
+ for c in P.EXCLUDED:
600
+ reason = (
601
+ f"only {c.n_normals} solid-tissue normals, below the floor of {P.MIN_NORMALS_FOR_POWER}"
602
+ if c.usable == "underpowered"
603
+ else "no solid-tissue normals, so no tumour-vs-normal contrast is possible"
604
+ )
605
+ lines.append(f"| {c.project} | **{c.symbol}** ({c.uniprot}) | {reason} |")
606
+ lines.append("")
607
+ return lines