wade-rnaseq 0.1.0__tar.gz

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 (99) hide show
  1. wade_rnaseq-0.1.0/.gitattributes +3 -0
  2. wade_rnaseq-0.1.0/.github/workflows/ci.yml +160 -0
  3. wade_rnaseq-0.1.0/.gitignore +29 -0
  4. wade_rnaseq-0.1.0/CONTRIBUTING.md +182 -0
  5. wade_rnaseq-0.1.0/Cargo.lock +440 -0
  6. wade_rnaseq-0.1.0/Cargo.toml +29 -0
  7. wade_rnaseq-0.1.0/ISSUES.md +176 -0
  8. wade_rnaseq-0.1.0/LICENSE +674 -0
  9. wade_rnaseq-0.1.0/PKG-INFO +197 -0
  10. wade_rnaseq-0.1.0/README.md +156 -0
  11. wade_rnaseq-0.1.0/docs/figures/gene.png +0 -0
  12. wade_rnaseq-0.1.0/docs/figures/stages.png +0 -0
  13. wade_rnaseq-0.1.0/docs/figures/volcano.png +0 -0
  14. wade_rnaseq-0.1.0/docs/manual.md +460 -0
  15. wade_rnaseq-0.1.0/docs/method.md +907 -0
  16. wade_rnaseq-0.1.0/docs/pvalue-review.md +562 -0
  17. wade_rnaseq-0.1.0/mamba_env.yaml +33 -0
  18. wade_rnaseq-0.1.0/notebooks/_mathjax-quiet.html +29 -0
  19. wade_rnaseq-0.1.0/notebooks/benchmark.qmd +954 -0
  20. wade_rnaseq-0.1.0/notebooks/demo.qmd +910 -0
  21. wade_rnaseq-0.1.0/notebooks/rna100k.qmd +510 -0
  22. wade_rnaseq-0.1.0/pyproject.toml +52 -0
  23. wade_rnaseq-0.1.0/rust/src/lib.rs +924 -0
  24. wade_rnaseq-0.1.0/src/wade/__init__.py +92 -0
  25. wade_rnaseq-0.1.0/src/wade/api.py +899 -0
  26. wade_rnaseq-0.1.0/src/wade/diagnostics.py +185 -0
  27. wade_rnaseq-0.1.0/src/wade/io.py +610 -0
  28. wade_rnaseq-0.1.0/src/wade/normalize.py +158 -0
  29. wade_rnaseq-0.1.0/src/wade/permutation.py +384 -0
  30. wade_rnaseq-0.1.0/src/wade/plotting/__init__.py +421 -0
  31. wade_rnaseq-0.1.0/src/wade/plotting/_matplotlib.py +240 -0
  32. wade_rnaseq-0.1.0/src/wade/plotting/_plotly.py +431 -0
  33. wade_rnaseq-0.1.0/src/wade/plotting/data.py +805 -0
  34. wade_rnaseq-0.1.0/src/wade/plotting/theme.py +146 -0
  35. wade_rnaseq-0.1.0/src/wade/pvalues.py +267 -0
  36. wade_rnaseq-0.1.0/src/wade/py.typed +0 -0
  37. wade_rnaseq-0.1.0/src/wade/quantiles.py +94 -0
  38. wade_rnaseq-0.1.0/src/wade/saddlepoint.py +340 -0
  39. wade_rnaseq-0.1.0/src/wade/stats.py +129 -0
  40. wade_rnaseq-0.1.0/src/wade/subset.py +429 -0
  41. wade_rnaseq-0.1.0/src/wade/thinning.py +306 -0
  42. wade_rnaseq-0.1.0/tests/conftest.py +348 -0
  43. wade_rnaseq-0.1.0/tests/fixtures/bh_padjust.json +1 -0
  44. wade_rnaseq-0.1.0/tests/fixtures/ecdf_denserank.json +1 -0
  45. wade_rnaseq-0.1.0/tests/fixtures/even_larger.json +1 -0
  46. wade_rnaseq-0.1.0/tests/fixtures/gate_closed.json +1 -0
  47. wade_rnaseq-0.1.0/tests/fixtures/gpd_branches.json +1 -0
  48. wade_rnaseq-0.1.0/tests/fixtures/log2scaled.json +1 -0
  49. wade_rnaseq-0.1.0/tests/fixtures/m2.json +1 -0
  50. wade_rnaseq-0.1.0/tests/fixtures/main.json +1 -0
  51. wade_rnaseq-0.1.0/tests/fixtures/nonames.json +1 -0
  52. wade_rnaseq-0.1.0/tests/fixtures/nperms0.json +1 -0
  53. wade_rnaseq-0.1.0/tests/fixtures/onesample.json +1 -0
  54. wade_rnaseq-0.1.0/tests/fixtures/quantile_type7.json +1 -0
  55. wade_rnaseq-0.1.0/tests/fixtures/refine.json +1 -0
  56. wade_rnaseq-0.1.0/tests/fixtures/tailconc.json +1 -0
  57. wade_rnaseq-0.1.0/tests/fixtures/tiesheavy.json +1 -0
  58. wade_rnaseq-0.1.0/tests/fixtures/tiny.json +1 -0
  59. wade_rnaseq-0.1.0/tests/fixtures/vecnorm.json +1 -0
  60. wade_rnaseq-0.1.0/tests/fixtures/weighted.json +1 -0
  61. wade_rnaseq-0.1.0/tests/fixtures/worked_example.json +1 -0
  62. wade_rnaseq-0.1.0/tests/fixtures/zerolib.json +1 -0
  63. wade_rnaseq-0.1.0/tests/portrun.py +65 -0
  64. wade_rnaseq-0.1.0/tests/test_chunking.py +192 -0
  65. wade_rnaseq-0.1.0/tests/test_core_completion.py +146 -0
  66. wade_rnaseq-0.1.0/tests/test_diagnostics_qc.py +95 -0
  67. wade_rnaseq-0.1.0/tests/test_divergences.py +105 -0
  68. wade_rnaseq-0.1.0/tests/test_fit_kernel.py +160 -0
  69. wade_rnaseq-0.1.0/tests/test_grid_cap.py +177 -0
  70. wade_rnaseq-0.1.0/tests/test_independent_reference.py +236 -0
  71. wade_rnaseq-0.1.0/tests/test_input_edges.py +87 -0
  72. wade_rnaseq-0.1.0/tests/test_io.py +407 -0
  73. wade_rnaseq-0.1.0/tests/test_kernel.py +176 -0
  74. wade_rnaseq-0.1.0/tests/test_layer0_fixture_integrity.py +187 -0
  75. wade_rnaseq-0.1.0/tests/test_layer1_grid.py +107 -0
  76. wade_rnaseq-0.1.0/tests/test_layer2_normalization.py +198 -0
  77. wade_rnaseq-0.1.0/tests/test_layer3_quantile_grids.py +129 -0
  78. wade_rnaseq-0.1.0/tests/test_layer4_reductions.py +173 -0
  79. wade_rnaseq-0.1.0/tests/test_layer5_null.py +119 -0
  80. wade_rnaseq-0.1.0/tests/test_layer6_pvalues.py +266 -0
  81. wade_rnaseq-0.1.0/tests/test_layer7_frame.py +161 -0
  82. wade_rnaseq-0.1.0/tests/test_layer9_validation.py +198 -0
  83. wade_rnaseq-0.1.0/tests/test_packaging.py +72 -0
  84. wade_rnaseq-0.1.0/tests/test_plotting.py +1154 -0
  85. wade_rnaseq-0.1.0/tests/test_promised_surface.py +112 -0
  86. wade_rnaseq-0.1.0/tests/test_ranking.py +113 -0
  87. wade_rnaseq-0.1.0/tests/test_result_memory.py +104 -0
  88. wade_rnaseq-0.1.0/tests/test_saddlepoint.py +296 -0
  89. wade_rnaseq-0.1.0/tests/test_scale.py +204 -0
  90. wade_rnaseq-0.1.0/tests/test_stage1_gemm.py +145 -0
  91. wade_rnaseq-0.1.0/tests/test_strata.py +171 -0
  92. wade_rnaseq-0.1.0/tests/test_subset.py +447 -0
  93. wade_rnaseq-0.1.0/tests/test_subset_kernel.py +376 -0
  94. wade_rnaseq-0.1.0/tests/test_thinning.py +240 -0
  95. wade_rnaseq-0.1.0/tools/bench_scaling.py +141 -0
  96. wade_rnaseq-0.1.0/tools/competitors.py +673 -0
  97. wade_rnaseq-0.1.0/tools/pvalue_meandiff.py +38 -0
  98. wade_rnaseq-0.1.0/tools/pvalue_saddlepoint_counts.py +47 -0
  99. wade_rnaseq-0.1.0/tools/pvalue_study.py +663 -0
@@ -0,0 +1,3 @@
1
+ # Every file is LF on every platform. The JSON fixtures under tests/ are
2
+ # byte-compared, so a CRLF checkout on Windows would fail the suite.
3
+ * text=auto eol=lf
@@ -0,0 +1,160 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+ inputs:
9
+ publish:
10
+ description: "Upload the built wheels and sdist to PyPI"
11
+ type: boolean
12
+ default: false
13
+
14
+ concurrency:
15
+ group: ${{ github.workflow }}-${{ github.ref }}
16
+ cancel-in-progress: true
17
+
18
+ jobs:
19
+ test:
20
+ name: ${{ matrix.os }} / py${{ matrix.python }}
21
+ runs-on: ${{ matrix.os }}
22
+ strategy:
23
+ fail-fast: false
24
+ matrix:
25
+ os: [ubuntu-latest, macos-latest, windows-latest]
26
+ python: ["3.10", "3.13"]
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: actions/setup-python@v5
30
+ with:
31
+ python-version: ${{ matrix.python }}
32
+ - uses: dtolnay/rust-toolchain@stable
33
+ with:
34
+ components: clippy
35
+ - uses: Swatinem/rust-cache@v2
36
+ - name: Install
37
+ run: |
38
+ python -m pip install --upgrade pip
39
+ pip install -e ".[test,io,plot]"
40
+ - name: Lint the kernel
41
+ run: cargo clippy --release -- -D warnings
42
+ - name: Test
43
+ run: pytest -q
44
+
45
+ oldest-numpy:
46
+ name: numpy floor (1.24)
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+ - uses: actions/setup-python@v5
51
+ with:
52
+ python-version: "3.10"
53
+ - uses: dtolnay/rust-toolchain@stable
54
+ - run: |
55
+ python -m pip install --upgrade pip
56
+ pip install -e ".[test]"
57
+ pip install "numpy==1.24.*"
58
+ pytest -q -m "not validation"
59
+
60
+ no-kernel:
61
+ name: NumPy fallback (kernel removed)
62
+ runs-on: ubuntu-latest
63
+ steps:
64
+ - uses: actions/checkout@v4
65
+ - uses: actions/setup-python@v5
66
+ with:
67
+ python-version: "3.12"
68
+ - uses: dtolnay/rust-toolchain@stable
69
+ - name: Build, then delete the kernel and run the readable path
70
+ run: |
71
+ python -m pip install --upgrade pip
72
+ pip install -e ".[test,io]"
73
+ find src/wade -name "_kernel*.so" -delete
74
+ python -c "import wade; assert not wade.permutation.HAVE_RUST_KERNEL"
75
+ pytest -q -m "not kernel"
76
+
77
+ wheels:
78
+ name: wheel / ${{ matrix.os }} ${{ matrix.target }}
79
+ runs-on: ${{ matrix.os }}
80
+ strategy:
81
+ fail-fast: false
82
+ matrix:
83
+ # One row per wheel PyPI needs. abi3-py310 means a single wheel per
84
+ # platform covers every supported Python. Every row builds on a native
85
+ # runner, so every wheel is import-tested.
86
+ include:
87
+ - { os: ubuntu-latest, target: x86_64 }
88
+ - { os: ubuntu-24.04-arm, target: aarch64 }
89
+ - { os: macos-latest, target: aarch64-apple-darwin }
90
+ - { os: macos-15-intel, target: x86_64-apple-darwin }
91
+ - { os: windows-latest, target: x64 }
92
+ steps:
93
+ - uses: actions/checkout@v4
94
+ - uses: actions/setup-python@v5
95
+ with:
96
+ python-version: "3.12"
97
+ - uses: dtolnay/rust-toolchain@stable
98
+ - name: Build wheel
99
+ uses: PyO3/maturin-action@v1
100
+ with:
101
+ command: build
102
+ target: ${{ matrix.target }}
103
+ manylinux: auto
104
+ args: --release --out dist
105
+ - name: Check the wheel installs and imports on its own
106
+ shell: bash
107
+ run: |
108
+ python -m pip install --upgrade pip
109
+ # Install the declared dependency first, then the wheel from dist and
110
+ # nowhere else, so nothing is pulled from PyPI under the same name.
111
+ pip install "numpy>=1.24"
112
+ pip install --no-index --find-links dist --no-deps wade-rnaseq
113
+ # A wheel that silently fell back to NumPy is the failure this job
114
+ # exists to catch.
115
+ python -c "import wade; assert wade.permutation.HAVE_RUST_KERNEL, \
116
+ 'wheel has no compiled kernel'; print(wade.__version__, 'kernel ok')"
117
+ - uses: actions/upload-artifact@v4
118
+ with:
119
+ name: wheel-${{ matrix.os }}-${{ matrix.target }}
120
+ path: dist/*.whl
121
+
122
+ sdist:
123
+ runs-on: ubuntu-latest
124
+ steps:
125
+ - uses: actions/checkout@v4
126
+ - uses: actions/setup-python@v5
127
+ with: { python-version: "3.12" }
128
+ - uses: dtolnay/rust-toolchain@stable
129
+ - uses: PyO3/maturin-action@v1
130
+ with: { command: sdist, args: --out dist }
131
+ - name: Check the sdist builds from scratch
132
+ run: |
133
+ python -m pip install --upgrade pip
134
+ pip install dist/*.tar.gz
135
+ python -c "import wade; assert wade.permutation.HAVE_RUST_KERNEL; print(wade.__version__)"
136
+ - uses: actions/upload-artifact@v4
137
+ with: { name: sdist, path: dist/*.tar.gz }
138
+
139
+ # Deliberately NOT triggered by a tag. A PyPI upload cannot be undone and a
140
+ # version number cannot be reused, so publishing is a button press: run the
141
+ # workflow manually with `publish` checked. Everything above still runs
142
+ # first, so nothing reaches PyPI that has not passed the full matrix.
143
+ #
144
+ # Needs a PyPI trusted publisher for this repo (pypi.org -> the project ->
145
+ # Publishing), and a GitHub environment named `pypi`. No API token.
146
+ publish:
147
+ needs: [test, oldest-numpy, no-kernel, wheels, sdist]
148
+ if: inputs.publish
149
+ runs-on: ubuntu-latest
150
+ environment: pypi
151
+ permissions:
152
+ id-token: write
153
+ steps:
154
+ - uses: actions/download-artifact@v4
155
+ with:
156
+ path: dist
157
+ pattern: "{wheel-*,sdist}"
158
+ merge-multiple: true
159
+ - run: ls -l dist
160
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,29 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .mypy_cache/
8
+ .venv/
9
+ dist/
10
+
11
+ # The compiled kernel (rebuilt by `pip install -e .`) and Rust build output
12
+ src/wade/_kernel*.so
13
+ src/wade/_kernel*.pyd
14
+ /target/
15
+
16
+ # Rendered notebooks carry data-derived values; the .qmd source is the artifact.
17
+ # A leading underscore marks a Quarto partial, which is source.
18
+ notebooks/*.html
19
+ !notebooks/_*.html
20
+ notebooks/*.pdf
21
+ notebooks/*.quarto_ipynb
22
+ notebooks/*_files/
23
+ notebooks/.quarto/
24
+ .quarto/
25
+
26
+ # Editors and OS
27
+ .vscode/
28
+ .idea/
29
+ .DS_Store
@@ -0,0 +1,182 @@
1
+ # Contributing
2
+
3
+ How to build WADE from source, run its tests, find your way around, and the
4
+ things that have cost time before. The method is in
5
+ [`docs/method.md`](docs/method.md); the work queue is
6
+ [`ISSUES.md`](ISSUES.md).
7
+
8
+ ## Build and test
9
+
10
+ ```bash
11
+ git clone https://github.com/mkiyer/wade && cd wade
12
+ mamba env create -f mamba_env.yaml && conda activate wade # Rust toolchain included
13
+ pip install -e . --no-build-isolation # builds the kernel
14
+ pytest -q # ~20 s
15
+ ```
16
+
17
+ Without conda: a Python 3.10+ virtual environment plus a Rust toolchain
18
+ ([rustup](https://rustup.rs); on Windows also the MSVC Build Tools), then
19
+ `pip install -e ".[all,test]"`. The `--no-build-isolation` flag only saves
20
+ pip fetching maturin into a temporary environment; drop it if maturin is not
21
+ installed. `maturin develop --release` is the fastest rebuild loop once
22
+ maturin is on your path.
23
+
24
+ ```bash
25
+ pytest -q -m "not kernel" # if you built without the Rust toolchain
26
+ pytest -q -m "not validation" # skip the simulations (~8 s)
27
+ cargo clippy --release -- -D warnings
28
+ python -c "import wade; assert wade.permutation.HAVE_RUST_KERNEL"
29
+ ```
30
+
31
+ CI runs the suite on three OSes × two Pythons, at the declared NumPy floor,
32
+ with the kernel deleted, and builds every wheel on a native runner. The
33
+ development environment has every optional dependency installed and
34
+ therefore cannot see the class of bug where a test assumes one; reproduce a
35
+ bare install with a venv and `pip install -e ".[test,io]"`.
36
+
37
+ ## Layout
38
+
39
+ ```
40
+ src/wade/
41
+ api.py wade(), wade_contrast(), WadeResult — the one driver, over gene chunks
42
+ io.py as_counts(), condition(), write_results(), manifest — the data boundary
43
+ normalize.py tpm_like() and the continuity jitter
44
+ quantiles.py the descending probability grid and type-7 quantiles
45
+ stats.py wade_stats(): the grids, mean_shift, w1, fc
46
+ subset.py the bridge, the subset test, affected_fraction, direction, subset_log2_fc, the bootstrap
47
+ thinning.py fit_fold_change(), thin_counts(), one_count() — the count-native shift correction
48
+ permutation.py draw_perms(), the two nulls, kernel dispatch, permutation_space(), detectability_floor()
49
+ pvalues.py empirical p, GPD refinement, BH
50
+ saddlepoint.py stage1="saddlepoint": the exact permutation tail of a subset sum
51
+ diagnostics.py wade_gene(), library_qc(), subset_drivers()
52
+ plotting/ theme.py (every colour and label) → data.py (pure NumPy dataclasses)
53
+ → _plotly.py / _matplotlib.py (thin renderers) → __init__.py (the plot_* functions)
54
+ rust/src/lib.rs three kernels: null_statistics, subset_null, fit_bisect
55
+ tests/ layered suite; tests/fixtures/*.json are permanent committed data
56
+ tools/ reproduction scripts: bench_scaling, pvalue_study, competitors
57
+ notebooks/ demo.qmd (the README's figures), benchmark.qmd, rna100k.qmd
58
+ ```
59
+
60
+ **Plotting is an extension.** `theme.py` imports nothing, `data.py` imports
61
+ no backend, the renderers import their library inside their functions, and
62
+ `import wade` stays NumPy-only; tests pin all of that, and a test greps the
63
+ package for colour literals outside `theme.py`. Every figure's arrays are a
64
+ dataclass with a `table()`, which is the seam a third renderer would be
65
+ written against (about 100 lines).
66
+
67
+ ## Rules
68
+
69
+ **Nothing changes a reported number.** `pytest -q` is the check, and the
70
+ parity ledger it prints at the end (worst relative deviation over every
71
+ fixture comparison) is the sharper one. Paths that do change numbers are
72
+ opt-in and named: `stage1="gemm"`, `stage1="saddlepoint"`,
73
+ `fit_backend="rust"`. `backend="auto"` must never let the machine pick the
74
+ answer.
75
+
76
+ **Measure before writing.** Every number in a docstring or document was
77
+ measured, not reasoned. Three claims written from theory turned out wrong
78
+ when run (the scan's argmax "obeys the arcsine law"; "the participation ratio
79
+ works on the log scale" without the fourth moment; "permutation gives the
80
+ right null for the subset test"). Build a planted-ground-truth simulation and
81
+ check.
82
+
83
+ **Prefer deleting to adding.** Before adding a parameter or a code path, ask
84
+ whether an existing one covers the case: CPM is `normalizer=1.0`; the chunked
85
+ driver with one chunk *is* the one-pass driver; a demonstration of why a
86
+ design was rejected belongs in a test, not on the entry point.
87
+
88
+ **A numerical routine checks its own answer.** Four separate bugs in the
89
+ saddlepoint solver each returned a plausible wrong p-value rather than an
90
+ error, and each was caught only because the final answer is verified against
91
+ both saddlepoint equations before it is returned. Anything approximating a
92
+ tail gets the same treatment.
93
+
94
+ ## Things that will bite you
95
+
96
+ **Tolerances go on the quantity's scale, never per element on something
97
+ that crosses zero.** `mean_shift` is a difference of two large nearly equal
98
+ means, and the bridge passes through zero; a per-element relative tolerance
99
+ on either measures cancellation, not arithmetic. Which BLAS and which libm
100
+ NumPy links decides summation order and the last bit of `log2`, and one
101
+ machine has one of each: a `1e-12` per-element tolerance passed for a
102
+ fortnight on the development laptop and failed on the first CI run under a
103
+ different BLAS, and a `1e-15` one passed on arm64 and failed on Linux. Use
104
+ `conftest.assert_close_scaled`.
105
+
106
+ **Chunking is bit-identical only because every coupling is structural.**
107
+ The jitter is drawn once and indexed per chunk; library sizes are always one
108
+ full-matrix pass (a column sum's association depends on blocking); the
109
+ fold-change fit and the thinning consume their random streams in gene order
110
+ across chunks (iteration-outer, chunk-inner; case blocks then control blocks).
111
+ And no chunk is ever a single row: NumPy reduces a `(1, m)` array by a
112
+ different code path than the same row inside a larger matrix, at last-ulp
113
+ cost, so `gene_chunks` refuses `gene_chunk=1` and folds a one-row remainder
114
+ into the previous chunk.
115
+
116
+ **The fit kernel is deliberately not bitwise.** No two binomial samplers
117
+ consume randomness alike, so `fit_backend="rust"` fits a slightly different
118
+ fold change for the same seed (measured max |log ratio| 0.035). Its
119
+ guarantees are different ones: deterministic given the seed, chunk- and
120
+ thread-invariant (each gene's stream is a function of `(seed, global gene
121
+ index)`), and statistically held to the NumPy path.
122
+
123
+ **Composition couples every gene.** Library-size normalization divides each
124
+ gene by a column total every gene contributes to, so a simulation with a
125
+ large fraction of strongly differential genes produces nonsense null genes
126
+ (five strongly-up genes among 205 moved null genes' `direction` from ~0 to
127
+ near −1). Keep the signal fraction of the *library mass* under ~10%, or pass
128
+ `lib_sizes=np.ones(n)` to bypass normalization.
129
+
130
+ **The subset stage needs `B ≥ 500` to refine at all** and the empirical floor
131
+ is `1/(B+1)`, so a figure of the subset stage on a few hundred genes needs
132
+ `nperms=2000` or no subset gene clears BH. A planted 5% subset in 200 cases
133
+ sits near the combinatorial floor and is found at raw `p` but not at FDR;
134
+ that is the floor doing its job.
135
+
136
+ **The fixtures cannot be regenerated, and do not need to be.** The R
137
+ implementation they were generated from was deleted. `tests/fixtures/*.json`
138
+ are permanent committed data whose correctness is established by
139
+ `tests/test_independent_reference.py`, which re-derives every value from
140
+ `numpy.quantile`, SciPy's BH and the closed forms and is forbidden from
141
+ importing WADE. Do that work first if you ever add a fixture; restore one
142
+ from git, never rebuild. Each double is stored as a decimal *and* a hex
143
+ float and the loader asserts they agree.
144
+
145
+ **plotly has quiet failure modes.** `add_hline` / `add_vline` / `add_vrect`
146
+ with `row=`/`col=` skip subplots that do not yet contain data, so add shapes
147
+ after the traces. An explicit range on one axis breaks autorange on axes
148
+ `matches`-ed to it. `shared_yaxes="rows"` shares every row; for one row use
149
+ `update_yaxes(matches=...)`. `make_subplots` silently drops an empty
150
+ `subplot_titles` entry. In matplotlib a colour bar is an `Axes`, so
151
+ `len(fig.axes)` is one more than the number of panels.
152
+
153
+ **The kernel's boundary.** The kernels return full `genes × permutations`
154
+ null matrices, not p-values: that is what the GPD needs, and it is the only
155
+ place a kernel bug is cleanly separable from a p-value bug. Type 7 is
156
+ reimplemented in Rust term for term with the no-interpolation guard on ties;
157
+ accumulation is sequential; parallelism is over genes so no accumulation
158
+ crosses a thread. Permutations are an input, a `(n_perms, samples)` label
159
+ matrix, on the same argument path production uses.
160
+
161
+ ## Test-suite conventions
162
+
163
+ - Layers 0–7 (`test_layer*.py`) validate the NumPy path against the fixtures
164
+ inside-out: intermediate quantities localize a disagreement, endpoints only
165
+ detect one. Stop at the first failing layer. `test_kernel.py` holds the
166
+ kernel to the same fixtures separately.
167
+ - Parity runs pin `alternative="greater"`; the package defaults to
168
+ two-sided.
169
+ - Every fixture comparison goes through `assert_close`, which records into a
170
+ ledger printed at the end of the run. Report the worst-case deviation, not
171
+ "the assertions passed". 1e-12 relative is a real bug; 1e-14 is summation
172
+ order.
173
+ - Every plotting test parametrizes over `available_backends()`, which yields
174
+ zero cases when neither backend is installed. Never index into it.
175
+
176
+ ## Notebooks
177
+
178
+ `quarto render notebooks/demo.qmd` regenerates `docs/figures/*.png` and the
179
+ numbers the README quotes; it is the master source for both.
180
+ `benchmark.qmd` takes about 20 minutes, almost all of it one permutation
181
+ sweep shared by nine methods. Quarto is installed separately; the conda
182
+ environment supplies the Jupyter kernel.