Status and handoff
State of the refactor, written so the work can be picked up by someone else.
Short version: the five pipeline steps are complete, run standalone, and have now run on
Sherlock as well as on a laptop. workflow/slurm_executor/ holds one plain sbatch script per step
— deliberately no orchestration layer, so a failure is attributable to the step rather than the
runner. The full simulation array at effect size 0.15 ran on 2026-08-13 as job 38849611, 1,000
tasks at 100 replicates, 999 of them completing; it is the source of every measured number below.
Since then, and not yet reflected in the older sections of this document, three things landed:
null_fit is implemented (Step 6a, below), the sceptre pin moved to 0.99.0, and sceptre now
carries a local patch that reuses the gRNA precomputation across replicates. All three are in
flight as of 2026-08-13 afternoon: the null-model array 38879935 is running, its merge 38879939
and the null_fit rerun of the simulation 38882207 are queued behind it.
The old-vs-new comparison has now run and passed (job 38916341) — the refactored pipeline
agrees with the old one on every check, including a zero difference in perturbed cells per pair
across all 34,886 pairs. See Comparison 1 below. Two equivalence checks had already removed the other
confounders: the version bump and the gRNA patch each provably change no number.
Still missing: a test suite (Step 8). Nextflow (Step 7) is in progress on the nextflow branch —
four of seven processes run, and PREPARE_SIM_INPUT’s output is byte-identical to the sbatch
runner’s. Step 9 has been retired: the gRNA precomputation reuse already collected most of what
it was for.
Done
The pipeline
Five standalone executables in src/, each with --help, none referencing snakemake@:
prepare_sim_input.R -> split_pairs.R -> run_power_simulation.R -> compute_power.R -> summarize_power.R
Shared code lives in lib/ at the repo root (cli.R, sim_input.R, pert_input.R, simulate.R,
sceptre_io.R, apply_patch.R), located from the calling script’s own path — each executable
resolves dirname(dirname(<its own path>))/lib — so it works both standalone and staged onto PATH
by a workflow engine.
Layout changed on 2026-08-13, ahead of the Nextflow work: bin/ became src/, and bin/lib/
was lifted to a top-level lib/. The old Snakemake implementation was removed at the same time and
lives on the legacy branch. Anything referring to bin/ predates this.
Measured improvements
| Before | After | How verified | |
|---|---|---|---|
| Input files required | 4 | 1 | the other three duplicate @discovery_pairs_with_info, @grna_target_data_frame, @discovery_result |
| Per-task read | 427 MB / 3.3s | 59 MB / 1.1s | timed readRDS on both |
| Per-task RAM | 1,774 MB+ | ~250 MB | object.size / peak heap |
| Dead code | — | 694 lines removed | 6 files, none reachable |
| Split imbalance | 1.18× | 1.000 | measured on 2,798 targets |
| Reproducibility | none | seeded and layout-invariant | identical p-values across 1×4 vs 2×2 replicate chunks, and across split layouts. On the same hardware — see the note below |
| Environment | 273-line pinned linux-64 conda | 11 direct specs, 2 platforms | pixi.lock solves for linux-64 + osx-arm64 |
Correctness fixes
| Issue | Consequence | Status on sample1 |
|---|---|---|
Dispersions in a list column with NULL holes; unlist() shortened the vector and rnbinom recycled it |
every gene after the first gap simulated with another gene’s dispersion | latent — 273 values where 292 expected, but all 239 tested genes had precomputations, so it never fired. Now a hard error |
max() over possibly-empty significant discoveries → -Inf |
zero power reported for every pair, silently | latent — 252 significant discoveries present. Now validated |
group_by(rep) %>% group_by(...) |
first grouping discarded | dead code, removed |
| Discovery-results file required but produced by no rule | undeclared fourth manual input | removed; read from the object |
| Sampled-controls path reordered covariates independently of matrix columns | covariates misaligned with counts | latent — the path never ran. Fixed |
No set.seed() anywhere |
no run reproducible | fixed; seeds derive from (seed, target, rep, effect_size) |
| Per-task seeding | num_batches silently changed results |
fixed and verified invariant |
effect_label trimmed trailing zeros unconditionally |
0.2 → column PowerAtEffectSize2 |
fixed; also renamed to power_at_effect_size_20 |
sparseMatrix local shadowing Matrix::sparseMatrix |
fragile | gone with the old script |
| Size factors permuted across cells before simulating | each cell’s library size was paired with a different cell’s perturbation status, since effect_size[i, j] is indexed by cell |
removed; each cell keeps its own size factor. This changes the RNG stream, so a seed no longer reproduces pre-refactor output draw for draw — see Methods |
Two of my own claims that measurement overturned
- Control-cell sampling is not a usable speed lever. It costs 21–60 % of power. Off by default. Table in Methods.
- The speedup from smaller matrices was ~7×, not the ~109× the size reduction suggested. Per-replicate cost is sub-linear in cell count; ~0.3s per replicate is fixed overhead.
Infrastructure
pixi.toml/pixi.lock— three environments (runtime / build / dev), sceptre pinned to 0.99.0 (3ba046b8, an untaggedmaincommit — v0.10.3 is still the newest tag, soSCEPTRE_REFnames a DESCRIPTION version, not a tag) plus a local patch, both guarded bypixi run check-api. See the sceptre section below.githooks/pre-commit— auto-fixes line endings, tabs (2 spaces for R/YAML, 4 otherwise), trailing whitespace, final newline; rejects files >512 KB, non-snake_case filenames, camelCase R identifiers.pixi run lintis the read-only repo-wide version- Documentation published to GitHub Pages
config/config.yml— parameters only; deadrepskey removed
Reference numbers
Needed to size cluster runs. Measured, not estimated. The right-hand column is Sherlock, on
day0_grna20_no_shuffle, and supersedes the laptop figures wherever they differ.
| Quantity | Laptop (sample1) |
Sherlock |
|---|---|---|
| Genes × cells | 292 × 586,309 | same dataset shape |
| Response matrix | 1,095 MB in RAM, 55.9 % dense | |
cells_in_use |
567,690 of 586,309 | |
| QC-passing pairs / targets | 32,386 / 2,798 | 34,886 / 3,026 (median 9 pairs/target, max 36) |
| Perturbed cells per pair | median 396 (IQR 195–542, max 1,927) | |
| Discovery p-value threshold | 7.71404 × 10⁻⁴ | |
One run_discovery_analysis() call |
3.4–4.8s | ~9.7s |
| Cost model, per (target, replicate) | — | 1.140s + 0.5561s × pairs — fitted on the 999 real tasks of 38887744 (null_fit + gRNA reuse), and the number to use. Predicts 635 CPU-h against 634 measured. Superseded: 5.076s + 0.5923s × pairs (job 38849611, before the gRNA reuse), 4.91s + 0.459s × pairs (36-target smoke test), 3.66s + 0.512s × pairs (3 profiled targets) |
| Per-target setup, once | — | 2.8–3.0s (0.03s per replicate at 100 reps) |
| Where a call spends its time | — | glm.fit 59 % total / 27 % self, rnbinom 7 % — measured with the inherited cache, so this is not the skipped per-gene null fit; see the null-model section |
| Total at 100 replicates | ~295 CPU-h per effect size | 999 CPU-h as_is, 634 CPU-h null_fit + gRNA reuse — both measured, not modelled (sacct over jobs 38849611 and 38887744). Use 634; the rest of this table predates the gRNA reuse |
| Per-task wall clock, measured | — | as_is: median 57.2 min, mean 60.0, p95 85.4, max 110.1. null_fit + reuse: median 36 min, max 61 (4 h requested → 2 h is ample) |
| Peak RAM, simulation task | 1.5 GB → request 4 GB | median 2.27 GB, max 3.27 GB over 1,000 tasks (8 GB requested → 6 GB keeps 1.8× headroom) |
Peak RAM, prepare_sim_input.R |
7.7 GB → request 12 GB | 16 GB requested, ~70s |
| Measured peak RAM, every step | — | prepare 7.4 GB, fit_null_models 7.6 GB (10 GB requested — only 1.3× headroom), power_simulation 2.9 GB, compute_power 2.1 GB (was requesting 32 GB, now 8), split/merge/summarize < 0.15 GB. From job 38978285’s Nextflow trace |
The ~295 CPU-hour figure was a laptop measurement and is roughly 3× optimistic. Sherlock’s
cores are slower per-thread than an M-series laptop, and sceptre’s per-call cost is untouched by the
refactor, so it dominates. Sized against the old pipeline’s own sacct record for its 2026-05-14
run, the refactor is still ahead on both axes:
| Old pipeline | Refactored | |
|---|---|---|
| Tasks | 1,004 | 1,000 |
| Mean per task | 78.2 min (range 0.8–134.5) | ~50 min |
| CPU-hours per effect size | 1,308 | ~814–858 |
| Wall clock | 14.9 h (Snakemake, throttled) | ~50 min (unthrottled array) |
Both ran 100 replicates on the same dataset, so this is like for like. The CPU win is 1.6×; the wall-clock win is mostly the unthrottled array rather than the code.
48 % of the cost is the per-target term — one run_discovery_analysis() call carries the full
586,309-cell bookkeeping however few gene pairs ride along. Targets can only share a call when their
gene sets are disjoint, which is Step 9 below. Consequences worth internalising before optimising:
- Halving the pairs saves ~26 %, not 50 %. Halving the replicates saves exactly 50 %.
- Filtering pairs for a second pass is much dearer than it looks: the 4,322 pairs whose interval straddles power 0.8 (12.4 % of pairs) are spread across 1,820 of 3,026 targets, so a top-up run pays 60 % of the per-target overhead to redo 12 % of the pairs — 82 % of its cost is overhead.
- Effect sizes cost the same as each other. At the current 635 CPU-h a six-point sweep is ~3,800 CPU-h (it was ~5,100 before the gRNA reuse), and the 2,900-task QOS budget still allows one wave: 480 splits × 6 effect sizes = 2,880 tasks, ~75 min each, so ~1.5 h wall clock.
Settled — which per-gene null model does the simulation test against?
Verdict: fit the null model on a null simulation of each replicate and reuse it (null_fit).
Today’s inherited-cache behaviour understates power, and the fix costs 1.7 %. Measured by job
38854980 and the threshold check 38861277; the numbers are below.
Before testing any pair, sceptre fits a Poisson GLM of the gene’s counts on the cell covariates
(perform_response_precomputation, precomputation_functions.R:13) — the null model the perturbed
cells are compared against. It skips that fit when @response_precomputations already holds an
entry for the response_id (medium_level_functs_v2.R:63, reached because
run_outer_regression <- calibration_check || control_group_complement and our
control_group_complement is TRUE).
Two measured facts follow:
glm.fitis 27 % self / 59 % total underRprof, against 7 % for our ownrnbinomdraws, so GLM fitting is the single largest cost in a call. That 59 % is not this precomputation. The profile was taken over the current path, with the inherited cache populated and the precomputation therefore skipped — so the 59 % is GLM work sceptre does anyway. Clearing the cache adds the precomputation on top of it, and the table below measures that: 4.3×, all of it in the per-pair term. What the resident 59 % actually is remains unidentified, and it bounds what any pooling optimisation such as Step 9 can recover.sceptre_template.rdsinherits 272 precomputations from the real discovery analysis, and they cover all 237 genes that have QC-passing pairs — none are refitted. So every simulated count is tested against coefficients fitted to real counts. Measured directly by job38854980: 237 of 237 present, 0 absent. The bias is therefore uniform rather than mixed across genes, which is easier to reason about but no more correct — a faithful emulation fits its null model on the data it is given, and the real analysis did exactly that.
The three configurations, measured
Job 38854980 ran all three on 3 targets × 5 replicates, rotating the order within each replicate so
no configuration is always first:
| What it does | Cost model | CPU-h per effect size | |
|---|---|---|---|
as_is |
inherited real-data cache — today’s behaviour | 5.85s + 0.839s × pairs | 1,305 |
cleared |
refits inside every call, on that call’s simulated counts — the faithful reference | 8.18s + 5.127s × pairs | 5,656 |
null_fit |
fitted once per (gene, replicate) on a null simulation, then reused | 5.51s + 0.891s × pairs | 1,327 |
cleared is 4.3×, and the extra cost sits in the per-pair slope, not the intercept — each pair
in a call is a distinct gene, so refitting scales with the genes tested. It is a reference, never a
production candidate. null_fit buys its fidelity for +1.7 % over as_is.
Treat the ratios as sound and the absolute CPU-hours as indicative only: this job timed as_is at
11.5s on the 5-pair target where the profiling run measured 6.15s, so its clock runs ~1.9× slow —
probably because it holds three caches and a null simulation in memory at once. That is also why the
1,305 CPU-h here disagrees with the ~858 in the table above. The 100-replicate array’s own sacct
record supersedes both.
Why as_is is not good enough
Aggregate p-value agreement looks reassuring and is misleading:
cleared vs null_fit n=265 max|diff|=0.012 median=1.2e-10 spearman=1.0000
cleared vs as_is n=265 max|diff|=0.08 median=4.6e-05 spearman=0.9990
Power is not aggregate. It is the fraction of replicates with p < 7.2629e-4, so only crossings of
that threshold matter, and a median difference of 4.6e-05 is ~6 % of the threshold itself. Counting
the calls instead of correlating the p-values (job 38861277, threshold_check.R):
cleared vs null_fit flips=0 / 265 (0.00 %) per-pair power identical
cleared vs as_is flips=7 / 265 (2.64 %) [cleared-only=7, as_is-only=0]
null_fitis exactly equivalent to the faithful reference — zero disagreements in 265 calls, and per-pair power identical to three decimal places.as_isis biased, one-directionally. All 7 flips go the same way:clearedcalls the pair significant andas_isdoes not, never the reverse (137 of 265 significant against 144). Random noise would split both ways — P ≈ 0.016 for 7–0 — soas_isunderstates power, by a mean |Δpower| of 0.026 on this sample.- It concentrates where it matters. Only ~37 of 265 p-values sit within a factor of 10 of the threshold, so roughly 19 % of near-threshold calls flip.
Caveat on the magnitude: 3 targets, 53 pairs, 5 replicates. The direction is solid and the
equivalence of null_fit is unambiguous; the 2.64 % figure is thin and should not be quoted as
precise.
Superseded on the magnitude. The full 100-replicate run under both configurations (see Consequences below) puts the mean shift at +0.0063, not the ~0.026 this sample gave — the direction held, the size did not. The 2.64 % flip rate was measured on 5 replicates, where a single replicate crossing the threshold moves power by 0.2; at 100 replicates it is 0.8 percentage points of pairs crossing the certification line.
Note the coefficients themselves differ substantially — inherited theta median 20.0 against 18.8 from the null fit, median worst-coefficient difference 7.77 — while the p-values barely move. sceptre’s conditional-resampling test is far more robust to the null model than the null model is stable.
Consequences
-
The effect size 0.15 array (
38849611) ranas_is, and the bias has now been measured directly rather than extrapolated. Both configurations exist at 100 replicates over all 34,886 pairs (power_as_is/andpower_null_fit/), and they are exactly paired — same simulated counts, so every difference is the null model with zero Monte-Carlo noise between them.as_isnull_fitMean power 0.5933 0.5996 (+0.0063) Certified ( power_ci_low≥ 0.8)13,094 (37.5 %) 13,374 (38.3 %) Ambiguous 4,382 (12.6 %) 4,408 (12.6 %) Underpowered 17,410 (49.9 %) 17,104 (49.0 %) The direction is confirmed and the magnitude is ~4× smaller than predicted.
as_isunderstates power: 11,649 pairs rise against 3,631 that fall, a 3.2:1 ratio that noise would not produce. But the shift is +0.0063 mean, 0.0100 mean absolute, against the ~0.03 this document extrapolated from 3 targets × 53 pairs × 5 replicates. That extrapolation was too thin, as it said at the time; treat 0.0063 as the number and the earlier 0.03 as withdrawn.It concentrates exactly where the mechanism says it should — in the transition band, not at the saturated ends:
as_ispower bandpairs mean Δ % moved 0.00–0.02 (floor) 4,411 +0.0010 9.7 % 0.02–0.20 4,409 +0.0094 52.7 % 0.20–0.50 4,411 +0.0121 73.4 % 0.50–0.80 5,894 +0.0130 73.5 % 0.80–0.98 8,937 +0.0047 48.6 % 0.98–1.00 (ceiling) 6,824 +0.0003 9.0 % Practical consequence:
null_fitis the correct configuration and should stay the default, but nothing built on theas_isnumbers was materially wrong — it moved 280 of 34,886 pairs across the certification line, 0.8 percentage points. Keep both outputs. - The pre-refactor pipeline inherited the same cache, so the old-vs-new comparison is unaffected:
both sides share the bias. Compare them
as_isagainstas_is. - Clearing the slot without supplying null fits gives
cleared, at 4.3×. The two changes must land together.
An earlier version of this section put the cost of cleared at ~2.4× per call and cited a p-value
divergence of 0.942 with Spearman 0.224 between as_is and null_fit. Both came from
profile_call.R, whose first configuration was labelled “refit, as the pipeline runs today” but
inherited the template’s cache and refitted nothing (line 100 only overwrites the slot when
precomp is non-NULL). The 2.4× was 1 / (1 - 0.59) applied to a glm.fit share that is not the
precomputation. The proper experiment gives as_is vs null_fit max 0.088 and Spearman 0.9989, so
both numbers are retracted. Why that script reported 0.942 at all is unexplained; it is
superseded by cache_experiment.R and should not be used.
Step 6a — null_fit: implemented, running
Not a one-line change, because the null fit has to be shared across array tasks. A gene’s null model is target-independent, so one fit per (gene, replicate) serves every target — but targets are spread over 1,000 splits, so fitting inside each task would pay for it 1,000 times instead of once. It is also effect-size independent: a null simulation has no knockdown, so one set of fits serves the whole sweep. 100 replicates, not 100 × 6.
What shipped:
src/fit_null_models.R— for each replicate, simulates a null matrix (no knockdown) and runs onerun_discovery_analysis()with@response_precomputationsempty, keeping only the resulting slot. An entry is 11 named coefficients plus athetascalar, so 100 replicates × 272 genes is a few hundred KB. Seeded from(seed, rep)only — deliberately not(seed, target, rep, effect_size), since the point is that it depends on neither target nor effect size.run_power_simulation.R --null-precomputationsassigns that replicate’s entry to@response_precomputationsinside the replicate loop (line 317), and validates that the bundle’s seed and replicate range match the run’s.- The inherited slot is cleared in
slim_sceptre_object()(lib/sceptre_io.R:199), afterbuild_dispersion_vectorhas read it (prepare_sim_input.R:259) — dispersions must keep coming from the real data, since they set the noise the simulation is meant to reproduce. Only the null model moves. Clearing is what makes an accidentalas_isimpossible to reintroduce. - Steps
02b(array, one task per replicate),02b_submit.shand02c(merge) inworkflow/slurm_executor/, with--null-precomputationsthreaded through04_run_power_simulation.sbatch.
The guard is in. run_power_simulation.R:166 errors if the template arrives with a non-empty
@response_precomputations and no --null-precomputations — rather than silently running as_is.
That is the bug that went unnoticed for the entire refactor, and it can no longer recur silently.
Done as of 2026-08-13. The null models are built (38879935, merged by 38879939 into
prepared/null_precomputations.rds), and the null_fit rerun of effect size 0.15 completed as
38887744: 1,000/1,000 tasks, every output at the exact expected row count, every output
carrying a matching .provenance sidecar. Results are in results/refactor/sim_null_fit/es0.15/.
The first attempt, 38882207, silently skipped all 1,000 tasks and was cancelled — see the
resume-check entry under Gotchas.
And it is 1.58× cheaper than the as_is run, not 1.7 % dearer.
as_is (38849611) |
null_fit (38887744) |
|
|---|---|---|
| Tasks completed | 999 / 1000 | 1000 / 1000 |
| Median task | 57.2 min | 36 min |
| Max task | 110.1 min | 61 min |
| CPU-h per effect size | 999 | 634 |
The null model itself costs ~1.7 % more, so the saving is not from it — it is the gRNA
precomputation reuse, which shipped in the same commit. That retires the “the saving is small”
caveat recorded under the equivalence check below: 1.02× was measured on the single worst-case split
(1 target, 36 pairs, in the cleared configuration), and at the median 9 pairs per target the fixed
per-target gRNA fit is a far larger share of the call. 1.58× at production scale is the number to
use; the step-10 figure measured what it measured, on a split chosen to be pessimal.
Note this also invalidates the cost model in the reference table for any future run: 5.076s +
0.5923s × pairs was fitted on as_is output with no gRNA reuse. Refit it on 38887744 before
sizing the next sweep. 02b_submit.sh had never run successfully before commit 08b0d04: PASSTHROUGH[@]: unbound
variable under set -u, the bash 4.2 empty-array bug already fixed in 04_submit.sh but never
back-ported. That is why null_models/ was empty.
Keep the as_is output rather than deleting it — it is the only direct measurement of how much the
bias moved real numbers at 100 replicates, and the threshold check that found the bias used 5.
sceptre: pinned to 0.99.0, and patched
Two changes to the dependency, both verified not to move any number.
The version bump
The pin moved from v0.10.3 (9faa4373) to 0.99.0 (3ba046b8, 2026-08-07). There is no
v0.99.0 tag — v0.10.3 is still the newest tag — so SCEPTRE_REF names an untagged commit’s
DESCRIPTION version. The slot set is byte-for-byte the same 55 slots (reformatted only), so
sceptre_object.rds and sceptre_template.rds serialised under 0.10.3 still deserialize, and
0.99.0’s NEWS is Bioconductor prep with no statistical change.
Two things the bump forced, both easy to miss:
cat()becamemessage().capture.output()only catches stdout, so the “considerparallel = TRUE” note would have leaked ~279,800 lines per effect size.suppressMessages()now wraps therun_discovery_analysis()calls inrun_power_simulation.Randfit_null_models.R; both wrappers are kept so the pin can move either way.- New Imports
parallellyandwithr, both on our code path.R CMD INSTALLenforces Imports, so they had to enterpixi.toml, forcing apixi.lockre-solve — which must run inside the ubuntu container, since the host is CentOS 7 (glibc 2.17) and pixi solves against the host’s virtual__glibc. That is00b_relock_env.sbatch(job38877284); the only package added wasr-parallelly 1.48.0. Commitpixi.tomlandpixi.locktogether.
Verified not to be a problem: the one RNG change on the association path
(set.seed(4) → withr::with_seed(4, ...) in partition_response_ids()) sits inside
if (parallel), and the pipeline always calls parallel = FALSE.
The patch — reusing the gRNA precomputation
sceptre’s CRT path regresses perturbation status on the covariates and draws synthetic treatment
assignments from the fitted probabilities. That fit depends on the gRNA-to-cell assignments and the
covariate matrix — never on the response counts. The simulation calls run_discovery_analysis()
once per (target, replicate) with only @response_matrix changing, so every replicate after the
first refit an identical model.
Unlike @response_precomputations there is no slot to hand it back through: fitted_probabilities
is a local variable inside the two CRT workhorses. So this ships as
patches/0001-reuse-grna-precomputation.patch, adding an optional argument with a NULL default —
unpatched behaviour is unchanged. Reuse is on by default; --no-grna-precomp-reuse turns it off.
Three design points are load-bearing — do not “simplify” them away:
- The cache holds coefficients, not fitted values. Fitted values are one double per cell, which
at this scale is ~2.3 GB held in the caller’s session. Reconstruction via
binomial()$linkinv(drop(X %*% coefs))is bit-identical —glm.fitcomputes thefitted.valuesit returns exactly that way, and the offset is zero here. Verified:identical()TRUE, max abs diff 0. - Each entry records
n_cells, and the guard checks it. The two workhorses fit on different cell sets but the same covariates, so the coefficient vector is the same length in both — length alone cannot tell a cache built for one path from a cache built for the other. - Never cache
crt_index_sampler_fast()’s output. The fitted probabilities are deterministic; the synthetic index sets drawn from them must be redrawn every call. Caching the indices would freeze the resampling distribution across replicates and destroy the CRT null — producing a power estimate that is garbage but looks plausible.
A target that is rank deficient within its own cells is omitted from the cache with a warning:
glm.fit returns NA for an aliased coefficient while keeping fitted.values valid, so a naive
X %*% coefs would yield all-NA.
Supporting machinery: lib/apply_patch.R is a pure-R unified-diff applier, needed because the
cluster runs everything in a stock ubuntu:22.04 container that has neither patch nor git. It is
stricter than patch(1) — no fuzz, no offset search, exact context match — so a moved pin fails
loudly. src/install_sceptre.R stamps RemoteSha and a LocalPatches md5 into DESCRIPTION and
short-circuits only when both match; src/check_sceptre_api.R asserts the patched API
separately, because an unapplied patch would otherwise surface only at the first
run_discovery_analysis() call, long after steps 01 and 02.
Equivalence check 10_grna_precomp_equivalence — does the patch change results? No. And the saving is small.
Job 38879261: outputs byte-identical between reuse and refit. That was the point of the job and
it is confirmed.
The cost result is weak, and much weaker than the retracted reasoning implied:
refit every replicate 620s (124.0s per call)
reuse per target 608s (121.6s per call)
speedup 1.02x (-1.9% wall clock)
split_0001 is 1 target with 36 pairs, so 5 replicates = 5 calls and reuse removes 4 of 5 gRNA fits:
12s / 4 ⇒ ~3s per gRNA fit. Two caveats pointing opposite ways: 1.9 % on 620s is within plausible
node-to-node variation, so 3s is an upper bound from a noisy difference, not a measurement — and
this is the least favourable target in the dataset, since 36 pairs is the maximum against a
median of 9, and the run was in the cleared configuration, which inflates the per-pair term ~5.75×.
Against the null_fit model a fixed ~3s is ~8 % at 36 pairs but ~22 % at the median 9.
The “up to 2.4×” claim stays retracted. The replacement is 1.58×, measured at production scale
— see the Step 6a table above, where the full null_fit array came in at 634 CPU-h against the
as_is array’s 999. Both caveats above pointed that way and the second one was right: this split is
the least favourable in the dataset. Do not quote the 1.02× as the saving; quote it as what the
equivalence check happened to cost on one worst-case split, which is not what it was measuring.
Equivalence check 11_sceptre_version_equivalence — does the version bump change results? No.
Job 38879265: stock v0.10.3 against patched 0.99.0, reuse off both sides, identical — the bump
does not change any number, so the old-vs-new comparison (Comparison 1 below,
07_compare_old_new.sbatch) is not confounded by it.
Both checks are exactly paired: seeds derive from (seed, target, rep, effect_size), which involves
neither the cache nor the sceptre version, so the simulated counts are bit-identical across each pair
and any difference would be attributable. Between them they isolate the two confounders the
old-vs-new comparison would otherwise carry.
Gotchas found along the way
Recorded because each cost real time and none is discoverable from the code.
vendor/sceptre.tar.gzis a snapshot ofmain, not the pin, and is untracked (vendor/is gitignored). Do not use it as an install source.- The login node caps open file descriptors at 256, hard, and
pixi lockexhausts it mid-solve (No file descriptors available (os error 24)). The solve must run in a job — hence00b_relock_env.sbatch. More generally, nothing heavy belongs on the login node. - pixi cannot install R packages from GitHub at all — conda channels only, and its git support is
PyPI-only. Hence the separate
pixi run setuptask plus a SHA pin. .githooks/pre-commitstrips trailing whitespace from source extensions, which would corrupt a.patchfile’s blank context lines..patchis not in itsis_source_filelist, so it is safe today — andapply_patch.Ralso treats a zero-length line as blank context. Do not add.patchto that list.- A patch’s context only guards the lines it touches. The API assertions in
check_sceptre_api.Rare the layer that actually catches a silently-unapplied patch. 02c’s dependency isafterok:38879935,afterany:<step 10>:<step 11>, and theafteranyis deliberate:02cwritesprepared/null_precomputations.rds, and the two equivalence checks test for that file at runtime, so an unguarded merge would silently flip them fromclearedtonull_fitdepending on scheduling.-
Step
04’s resume check skipped all 1,000 tasks of thenull_fitrerun. Job38882207reported 1,000 successes in ~15 s each and recomputed nothing:outwas${SIM_DIR}/es${ES}/${SPLIT_NAME}.tsvwith no reference to the null-model configuration, and “complete” was judged by row count — which is identical under every configuration, since the null model does not change how many rows come out. So theas_isoutput from38849611satisfied the check. The R-level guard atrun_power_simulation.R:166cannot catch this, because R never starts. Worse, the skip is the only thing that prevented a second bug: step 04 now passes--null-precomputationsunconditionally, so had it run it would have overwritten theas_isbaseline in place — the same directory09_compare_null_fit.sbatchrequires as theas_isside.Fixed two ways.
NULL_MODEL_CONFIGis now part of the output path (SIM_DIR=${OUTDIR}/sim_${NULL_MODEL_CONFIG},POWER_DIRlikewise), so two configurations can no longer address the same file; theas_isbaseline moved toresults/refactor/sim_as_is/and is named explicitly byAS_IS_SIM_DIR. And each output now carries a.provenancesidecar recording the configuration, the null-bundle md5, reps, seed, guide-sd and effect size — the resume check requires it to match, so an output produced differently is redone rather than trusted. A missing sidecar means the file predates the check and cannot be vouched for, so it is also redone. Resubmitted as38887744. - Step
03’s replicate-count check compared against stale output. It failed with “971 rows, 376 pairs, 109 with != 2 reps” while the three files the run actually wrote were each exactly 36 pairs × 2 reps. The directory held output from an earlier 280-split run, and becausesplit_pairs.Rpads the index to the width of--n-splits, those files are namedsmoke_split_001_*againstsmoke_split_0001_*— the check’s glob could not tell them apart. Fixed in628d5c9: step 03 now records the outputs it writes and checks only those.
Left to do
Step 7 — Nextflow + SLURM profile — in progress, on the nextflow branch
Four of seven processes exist and run: PREPARE_SIM_INPUT, SPLIT_PAIRS, FIT_NULL_MODELS,
MERGE_NULL_MODELS. Still to write: POWER_SIMULATION, COMPUTE_POWER, SUMMARIZE_POWER.
Minimal DSL2, not nf-core scaffolding — same principle as workflow/slurm_executor/: no framework
layer, so a failure is attributable to the step rather than the runner. The sbatch executor stays
until Nextflow reproduces a run; it produced every measurement in this document.
main.nf the DAG
nextflow.config params and profiles
conf/base.config measured resources, per process
conf/sherlock.config SLURM + apptainer + pixi
config/config.yml production parameters
config/test.yml 3 splits x 2 replicates
modules/local/*.nf one file per process
workflow/nextflow/run.sbatch the driver, which runs as a job
The DAG, which is not the shape planned above — that predates null_fit:
samplesheet -> PREPARE_SIM_INPUT ---+-> SPLIT_PAIRS -----------------+
| |
+-> FIT_NULL_MODELS (x reps) |
-> MERGE_NULL_MODELS -------+
v
POWER_SIMULATION (split x effect size)
-> collectFile by (sample, effect size)
-> COMPUTE_POWER -> SUMMARIZE_POWER
FIT_NULL_MODELS hangs off PREPARE_SIM_INPUT rather than off SPLIT_PAIRS because a gene’s null
model is fitted on a null simulation and so depends on neither the target nor the effect size. One
set of fits serves every split and every effect size in a sweep — 100 replicates, not 100 × splits ×
effect sizes. Making it a sibling rather than a descendant is what expresses that.
The design question that had to be settled first
The sbatch runner does cd $REPO_ROOT && pixi run. Nextflow cannot: a task must run in its own work
directory or its outputs are invisible to the engine. Two facts, both verified before building on
them, make it work anyway:
pixi run --manifest-path <repo>/pixi.tomlpreserves the working directory (tested from/tmp), so the task’s outputs land in the task directory.R_LIBS_USERis derived from$PIXI_PROJECT_ROOT, not the cwd, so the environment follows the manifest.
Every process invocation is built on that pair.
Validated at production scale
The Nextflow runner reproduces the sbatch runner exactly, on a full 1,000-split run. Job
38978285, 2h19m wall clock, all seven processes, 34,886 pairs at 100 replicates:
| Pairs matched | 34,886 |
| Pairs whose power differs | 0 (max abs difference 0) |
| Pairs whose certification flips | 0 |
That is the check that decides whether the two runners can be considered equivalent, and it passes on the deliverable rather than on an intermediate. Note the per-replicate p-values do still differ in the far tail between the two runs — see “Reproducibility is bitwise on one node” — but not one of those differences reaches the power estimate.
Two incidental findings from that run:
results/day0_grna20/sceptre_object.rdsandresults/day0_grna20_no_shuffle/sceptre_object.rdsare byte-identical. The_no_shufflesuffix describes how the old pipeline processed the sample — with the size-factor permutation removed — not a different input. The dataset inventory under Step 11 lists them as separate datasets, which is true of their old-pipeline outputs and false of their inputs. Running the refactored pipeline on both is redundant.- Preemption on
ownersis handled. Three tasks were killed with exit 143 and retried automatically byconf/base.config’serrorStrategy, without intervention. The sbatch runner needs a resubmission plus its resume check to do the same.
Verified so far
PREPARE_SIM_INPUT’s output is byte-identical to the sbatch runner’s — all five files, both
.rds objects included (sim_input.rds 16,057,759 bytes; sceptre_template.rds 46,520,066). That
is a much stronger result than “it ran”: a single difference anywhere in the container, pixi
environment, R version or patched sceptre would perturb the serialised objects. The environment
integration is therefore the same one the measurements came from.
Four traps, each of which reported something other than its cause
Recorded because every one cost time and none is guessable from the error text.
.ifEmpty { error ... }aborts every run. Nextflow invokes the closure while building the DAG, not when the channel proves empty, so a guard for a malformed samplesheet fired unconditionally — and, being the first error raised, printed “samplesheet has no rows” in place of the real failure further down, masking trap 2 completely. Validate the file eagerly instead.takerejects adeflocal or anas intcast. Both arrive wrapped in aPojoWrapperand fail withMissing process or function take([DataflowStream, PojoWrapper]), which reads like a missing operator. A literal or aparams.*value works; nothing else tried did. Bound the range when you build the channel rather than trimming it afterwards where you can.-profile testwas silently doing almost nothing. Nextflow gives-params-fileprecedence over profile params, and the driver always passes one, so every test override sharing a name with a production parameter was discarded — the run used 2 null chunks of 1 replicate where the profile asked for 1 of 2, andnum_replicatesstayed at 100. A “test” run would have been a full-size run. Test settings now live inconfig/test.yml, selected withPARAMS_FILE. Profiles choose where a run executes; params files choose what it runs.- A local
def scratchis shadowed by thescratchprocess directive.conf/sherlock.configsetsprocess.scratch = false, so"--bind ${scratch}:${scratch}"expanded to--bind false:falseand apptainer died with “unable to add false to mount list: destination must be an absolute path”. It had also silently reachedPIXI_CACHE_DIRand friends. The variables are namedscratchDir/groupHomeDirfor that reason — do not tidy them back.
Two smaller ones: the driver must resolve REPO_ROOT from scontrol show job ... Command= rather
than $BASH_SOURCE, because Slurm copies the script into its spool directory; and Nextflow’s
file() resolves a relative path against launchDir and returns an absolute path, so
file(p).isAbsolute() is always true and cannot be used to test whether the user gave a relative
path.
Notes still standing
- The combine step needs no script:
collectFile(keepHeader: true, skip: 1)replaces the deletedcombine_sceptre_power_analysis.R. - Resources come from the measured table above, not the old Snakemake guesses (which asked 8 GB for
simulation and 64 GB for prepare).
conf/base.confignames the source of each. cpus 1on the simulation process — sceptre runs withparallel = FALSEand parallelism comes from the fan-out.- The driver runs as a job, never on a login node: it is a JVM that lives as long as the pipeline.
NXF_HOMEandNXF_WORKare redirected to$GROUP_HOMEand$SCRATCH, since the defaults are~/.nextflowand./workand$HOMEis 15 GB. nextflow run . -profile stub -params-file config/test.yml -stub-runchecks the wiring in seconds without touching the cluster. Thestubprofile usesresourceLimitsso the measured requests do not have to be rewritten to fit a login node.RemovingDone ahead of it, on 2026-08-13, once Comparison 1 had passed and the old implementation had nothing left to prove.Snakefile,rules/andR/belongs to this step.Snakefile,rules/,R/andenvs/are gone from this branch and preserved onlegacy.pixi run lintis now green — those files were the only things it flagged.
Why not sceptre’s own Nextflow pipeline
sceptre now ships one (timothy-barry/sceptre-pipeline). It is the right tool for a real
discovery analysis and the wrong one for a power simulation. Evaluated against a clone of it:
- It requires ondisc matrices. Every script calls
read_ondisc_backed_sceptre_object(sceptre_object_fp, response_odm_fp, grna_odm_fp), with no in-memory path. Using it would mean serialising each simulated matrix to ODM. - The simulation needs one object per gene-disjoint batch, not one per replicate — the same constraint as Step 9, since a gene is paired with ~147 targets. That is 100 × 300 = 30,000 objects for one effect size, each needing its own ODM.
- It re-runs everything upstream per object. The workflow is
set_analysis_parameters -> prepare_assign_grnas -> assign_grnas -> combine -> run_qc -> prepare -> analysis, unconditionally;pipeline_stoptruncates the tail but cannot skip the head. gRNA assignment alone is 43,432 gRNAs at their own2s/gRNA heuristic, ~24 CPU-hours per object, none of it dependent on the simulated response matrix. - Its
0.05sper pair is a walltime heuristic, not a benchmark —run_association_analysis_time_per_pair, used to size--time. It is not comparable to our measured per-pair cost.
What is worth taking from it: prepare_association_analyses.R sorts pairs by response_id and pods
on that key, so all ~147 pairs of a gene share one @response_precomputations entry — one GLM fit
serving 147 tests. That is the same insight as Step 9 (pool more pairs per call) and it is what led to
the null-model question above.
Reproducibility is bitwise on one node, not across the cluster
Found on 2026-08-14 while checking the Nextflow runner against the sbatch one on the same three splits, at the same seed. Two of the three splits came out byte-identical. The third did not:
| Rows compared | 72 |
fold_change differing |
7, max relative 1.2 × 10⁻¹⁵ (~5 ulps) |
p_value differing by >10⁻⁶ relative |
4, all at p < 10⁻²⁵ |
| Largest single difference | 2.1 × 10⁻⁸¹ against 9.2 × 10⁻⁸⁰ |
| Replicates changing significance | 0 |
This is not a different RNG stream — that would perturb every row, not four of seventy-two. It is last-bit floating-point difference between CPU generations (this cluster spans six; see the node table under Open questions), amplified by sceptre’s skew-normal tail approximation, which is very sensitive in the far tail and nowhere else. A p-value of 10⁻⁸¹ is zero for every purpose the pipeline has.
What the seeding guarantees is the random draws, not the arithmetic. So:
- The same seed on the same node type reproduces a run bit for bit.
- Across node types, expect agreement to ~15 significant figures on estimates, and agreement on every significance call — which is what power is computed from, and therefore what matters.
- Do not write a test, a comparison or an acceptance criterion that demands byte-identity of
simulation output.
workflow/nextflow/verify_against_sbatch.sbatchcompares numerically and asserts zero significance flips, which is the correct bar; an earlier version demanded byte-identity and failed on hardware rather than on the pipeline.
Step 8 — tests and comparisons
-
Unit tests— done: 62 tests intests/testthat/, run withpixi run testorsbatch workflow/slurm_executor/12_unit_tests.sbatch(R does not belong on a login node). They cover the pure functions only — no sceptre, no lab data, no cluster — and every one of them corresponds to an entry under “Correctness fixes” above:File Covers test-stats.Rwilson_intervalagainst published values and at both boundaries, where the normal approximation collapses;effect_labelpinning the0.2 → "2"bugtest-simulate.Rcenter_effect_size_matrixputting each gene’s perturbed mean on its target;create_effect_size_matrixorientation and clamping;build_dispersion_vectorerroring rather than recyclingtest-seeding.Rderive_seedseparating every key component, and being invariant to how replicates are chunked — the property that makes--n-splitsa purely computational knobTwo of these tests failed on first run because the tests were wrong, not the code: the effect-size matrix is genes × cells, not cells × genes (
run_power_simulation.R:314reorders columns by cell), andinit_seedlogs its seed deliberately. Both were corrected against the actual contract rather than by loosening the assertion — a test encoding a wrong assumption is worse than no test, and the transposed one would have passed a genuinely broken implementation.wilson_intervalandeffect_labelmoved tolib/stats.Rto make this possible; they were defined insidecompute_power.Randsummarize_power.R, where testing them meant invoking a command line.Not covered yet: split binning conservation, which is enforced in
SPLIT_PAIRSand02_split_pairs.sbatchat runtime rather than by a unit test. -
Synthetic test data generator— done:src/make_test_data.Rbuilds a sceptre object the whole pipeline runs on, in 43 seconds, with no lab data.sbatch workflow/slurm_executor/13_make_test_data.sbatchbuilds it and then runs all six steps on it end to end. Latest: 360 QC-passing pairs, 27 significant discoveries, threshold 7.3 × 10⁻³, 30 precomputations, 648 KB.Built through sceptre’s own API —
import_data→set_analysis_parameters→assign_grnas→run_qc→run_discovery_analysis— rather than by fabricating slots, so it cannot drift from the pinned library. Two constraints are not obvious and both were found by the object being rejected:- It has to plant real knockdowns.
discovery_threshold()errors when nothing is significant, deliberately, because the old code returned-Infthere and reported zero power for every pair. Pure noise produces an unusable object, not a boring one. - Cells must carry a variable number of gRNAs. With exactly one each,
grna_n_nonzerois constant, collinear with the intercept, and sceptre rejects the formula as containing “redundant information”.moi = "high"for the same faithfulness reason: it is what makes the control group the complement, which is the CRT path production actually uses.
The object is not committed —
tests/data/is gitignored. It is 648 KB against the pre-commit hook’s 512 KB ceiling, and regenerating it is the point of having a generator.sceptredatawas considered instead. It is not on conda, not installed, and not even in sceptre’sSuggests, so it would need the same SHA-pin-and-install machinery as sceptre itself; and since it ships raw matrices rather than asceptre_object, the API calls above would still have to be made by hand. Reasonable, but it removes less than it adds. - It has to plant real knockdowns.
Old-vs-new comparison— done and passed, job38916341; see Comparison 1 below.- Two-stage replicate allocation — documented in
Choosing num_replicates but not
orchestrated. The scripts already support it through
--rep-offset.
Step 9 — batch gene-disjoint targets into one sceptre call — RETIRED, do not build this
The gRNA precomputation reuse already collected most of what this was for, by a different
mechanism. Step 9 amortises the per-call setup by putting many targets in one call; the patch
eliminated the dominant component of that setup outright, by caching the fit instead. Refitting the
cost model on 38887744 shows what is left:
| Per target | Per pair | Per-target term | Step 9 ceiling | |
|---|---|---|---|---|
as_is (job 38849611) |
5.076s | 0.5923s | 427 CPU-h (42.6 %) | 1.63× |
null_fit + gRNA reuse (38887744) |
1.140s | 0.5561s | 96 CPU-h (15.1 %) | 1.16× |
Batching 302,600 calls into ~30,000 would leave ~10 CPU-h of per-target overhead, so it now saves about 86 CPU-h per effect size out of 635 — against the ~385 it promised. That is not worth a refactor that touches seeding, splitting and the target loop, and whose two open risks were never resolved: a 194-gene batch needs ~0.9 GB for the count matrix and as much again for the effect-size matrix against ~54 MB per target today, and sceptre’s per-call cost was only ever shown linear over 5–36 pairs, not the 194 a batch would carry.
The per-pair term is now 85 % of the bill, and Step 9 explicitly does not move it — a gene is still simulated once for every target it pairs with. Any further optimisation has to attack per-pair work, not per-call setup.
The reasoning below is kept because the analysis is still correct and the batching argument is reusable if the per-target term ever grows back.
Originally: the largest single saving left on the table, up to 1.8×, worth about 370 CPU-hours per effect size. Costed below because the reasoning is easy to get wrong in both directions.
Where the cost is. The unit of work is one run_discovery_analysis() call per (target,
replicate) — 3,026 × 100 = 302,600 calls per effect size — and each one carries the full
586,309-cell setup regardless of how few gene pairs it covers. That per-call term is 4.91s of the
4.91s + 0.459s × pairs model, so it is 413 of the 858 CPU-hours: half the bill is setup paid
and over.
Why the obvious fix does not work. The tempting version is one simulated object per replicate covering all 34,886 pairs, so the setup is paid 100 times instead of 302,600. It is not valid. Each gene is paired with 147 targets on average, up to 299 — only 10 of 237 genes belong to a single target — and pair (A, g) needs gene g knocked down in A’s perturbed cells while pair (B, g) needs it knocked down in B’s. One response matrix cannot hold both. Applying every knockdown at once puts B-perturbed cells, also knocked down, into A’s control group, which shrinks the contrast and understates power. The pre-refactor pipeline was per-target for the same reason; this is not an artifact of the refactor.
What does work. Two targets can share a call whenever their gene sets are disjoint, because a
knockdown on gene gA never touches gene gB’s row. Cell overlap is fine: a cell with gRNAs for
both A and B is in each one’s treatment group for its own gene, and unmodified for the other’s.
Greedy first-fit over the real pair table gives 300 batches, against a lower bound of 299 forced
by the gene that appears in 299 targets — so greedy is essentially optimal here.
| Now | Gene-disjoint batches | |
|---|---|---|
| sceptre calls per effect size | 302,600 | 30,000 (300 batches × 100 reps) |
| Per-call overhead | 413 CPU-h | 41 CPU-h |
Per-pair work (rnbinom draws) |
445 CPU-h | 445 CPU-h |
| Total | 858 | 486 CPU-h — 1.77× |
Batches hold at most 31 targets, 194 pairs and 194 of the 237 genes.
The per-pair term does not move, and that bounds the win. Gene g is still simulated once for
every target it pairs with, so the total rnbinom volume is unchanged. This is 1.8×, not the
10× the call-count ratio suggests.
parallel = TRUE is a different question and probably not the lever. sceptre’s internal
parallelism forks across pairs within a call; the array fan-out already provides that parallelism
across nodes, without fork overhead and without pinning a replicate to one node’s cores. It does not
reduce the number of calls, which is where the waste is. Batching and who-schedules-the-parallelism
are orthogonal.
Three things to check before building it.
-
Settled by the array: the per-call overhead is 427 CPU-h and Step 9’s ceiling is 1.63×. Fitting
time = reps × (a × targets + b × pairs)over all 999 completed tasks gives 5.076s + 0.5923s × pairs per (target, replicate) — 1,001 CPU-h predicted against 999 measured, so the model is sound. The per-target term is 427 CPU-h, 42.6 % of the bill.Batching 302,600 calls into ~30,000 leaves ~42 CPU-h of overhead, so 427 + 574 → 42 + 574 ≈ 616 CPU-h, a 1.63× saving worth ~385 CPU-h per effect size.
This refutes the previous revision of this point, which used a three-target profiled fit (
3.66s + 0.512s × pairs) to argue the intercept was mostly amortizable and the ceiling only ~1.5×. The real intercept is 5.076s, close to the original 36-target estimate of 4.91s. That three-target fit was thin and this document said so; it was still wrong to revise the headline number on it. The slope was the part the smoke test got wrong: 0.5923s against 0.459s, 29 % low. - Memory. A 194-gene batch is 194 × 586,309 dense doubles ≈ 0.9 GB for the count matrix and as much again for the effect-size matrix, against ~54 MB per target today. The 8 GB request may hold but has not been tested.
- Linearity. The
0.459sslope was fitted over 5–36 pairs per call. Batches carry up to 194, and sceptre’s per-call cost has not been shown to stay linear that far out.
Shape of the change. A batching function (first-fit over gene sets, ordered deterministically so
layout is reproducible), the target loop in run_power_simulation.R iterating batches instead of
single targets, and split_pairs.R splitting on batches, not targets. Seeds must stay derived
from (seed, target, rep, effect_size) so results remain invariant to the batch layout, exactly as
they are invariant to the split layout today — that invariance is also what makes the two designs
directly comparable pair by pair.
Step 10 — re-derive the environment — DONE
Audited 2026-08-14. The list was in better shape than expected: one package was in the wrong environment, and nothing else needed to change.
Our own code uses only Matrix, sceptre and optparse via library(), plus stats,
utils, methods, tools and testthat via ::. Everything else in pixi.toml is there for
sceptre’s benefit, which is where the real question was.
r-bh moved to the build environment. sceptre declares LinkingTo: Rcpp, BH — header-only
Boost, used to compile the C++ and never loaded at runtime. It had been sitting in the runtime
environment that every Nextflow task activates, under a comment describing it as a “NAMESPACE
import”, which it is not: sceptre’s NAMESPACE imports only Matrix and Rcpp. That wrong comment
is presumably how it got there.
src/audit_dependencies.R is the tool that settled it, and it separates three things that get
conflated:
| Meaning | For sceptre 0.99.0 | |
|---|---|---|
| NAMESPACE imports | loaded on every library() call, unconditional |
Matrix, Rcpp |
:: references |
loaded only if that line executes | 15 packages, incl. dplyr, data.table, purrr, crayon, parallelly, withr |
LinkingTo |
compile-time headers, never loaded | Rcpp, BH |
BH was the only declared dependency with no reference anywhere in the package’s code. Everything
else earns its place, so the rest of the runtime list stands as documented in pixi.toml.
What the audit cannot tell you is which of those 15 :: references lie on our path
specifically — that needs coverage instrumentation. It narrows the question to a list short enough
to reason about, and the synthetic object now makes a candidate removal cheap to test by running
the whole pipeline without the package.
The re-solve was a no-op apart from the intended change (job 39034832): four lines removed
from pixi.lock, no package added, removed or moved version. r-base stayed at
4.4.3-h35b0bb1_11. Verified afterwards by job 39037197, which rebuilt the synthetic object and
ran all six pipeline steps against the new environment — if BH were needed at runtime, sceptre
would not load at all.
A caveat that outlived this step. Nearly every spec in [dependencies] is "*", so the
lockfile is the only thing pinning versions and any re-solve is a gamble on what conda-forge has
published since. This one happened to come back clean; the next one may not. Pinning the specs to
their locked versions would make future re-solves honest, and is worth doing before the next
dependency change rather than during one.
Step 11 — fit the power curve and run three effect sizes instead of six
Prototyped in src/fit_power_curve.R, validated on one dataset, not yet adopted. Halves the cost of
a sweep (~2,575 CPU-hours at 100 replicates on this dataset) and makes the minimum detectable effect
size continuous rather than snapped to whichever effect sizes were run.
The model is derived, not curve-fitted. For a Wald-type test at a fixed threshold,
power(effect_size) = Phi(beta / SE - z), beta = -log(1 - effect_size), z = qnorm(1 - alpha)
beta is the effect on the scale the test works on, SE collects everything pair-specific
(perturbed cells, expression, dispersion), and z is fixed by the discovery threshold and shared by
every pair. On the probit scale that is a straight line through -z with slope 1/SE: one free
parameter per pair, so three effect sizes leave two degrees of freedom to check the fit rather
than just enough to force it. src/fit_power_curve.R fits it as a binomial GLM with a probit link,
no intercept and -z as an offset — a GLM rather than least squares on qnorm(power) because 0/100
and 100/100 still carry information about the slope, whereas qnorm() would be infinite there.
Validation. Held-out test on dc_tap_paper_wtc11_no_shuf, which has a complete six-point sweep
(0.05 / 0.1 / 0.15 / 0.2 / 0.25 / 0.5) over 6,574 pairs at 100 replicates. Fit on three of them,
predict the other three, compare against what was measured:
| Fit grid | pairs fitted | MAE (held out) | p90 error | noise floor | MDES exact | MDES ±1 step |
|---|---|---|---|---|---|---|
| 0.05 / 0.25 / 0.5 | 6,547 | 0.0488 | 0.126 | 0.0201 | 82.6 % | 98.0 % |
| 0.05 / 0.1 / 0.15 | 6,405 | 0.0270 | 0.083 | 0.0067 | 82.7 % | 96.1 % |
| 0.05 / 0.15 / 0.5 | 6,548 | 0.0338 | 0.104 | 0.0167 | 85.2 % | 98.3 % |
| 0.05 / 0.1 / 0.25 | 6,477 | 0.0167 | 0.056 | 0.0104 | 89.0 % | 99.2 % |
“Noise floor” is the mean binomial standard error of the measured value being compared against, so the best grid predicts held-out power to within about 1.6× the noise of simply measuring it, and reproduces the six-point sweep’s minimum detectable effect size exactly for 89 % of pairs.
Two supporting checks on the same data. The functional form holds: fitting per pair on all six points gives a probit-scale residual sd of 0.132 (median), against ~0.13 expected from 100-replicate binomial noise alone at power 0.5 — the straight line fits as well as the data can distinguish. And monotonicity holds: of 32,870 consecutive-effect-size comparisons only 220 (0.67 %) decrease, largest decrease 0.060, which is about one standard error of a difference.
Grid placement is dataset-specific and matters more than the number of points. wtc11 is well
powered and transitions between 5 % and 15 %; day0_grna20_no_shuffle is not, and 84 % of its pairs
transition between 5 % and 25 %. Projecting each day0 pair’s curve from its measured 0.15 power:
| Grid | day0 pairs with ≥1 point where power is 0.1–0.9 |
|---|---|
| 5 / 25 / 50 | 26.3 % |
| 5 / 15 / 50 | 60.4 % |
| 10 / 20 / 35 | 100.0 % |
| 5 / 15 / 25 / 50 | 72.3 % |
A one-parameter sigmoid is only well determined by points away from 0 and 1, so a grid that brackets the transition rather than sampling it wastes replicates. Pick the grid from a cheap pilot — one effect size at 30 replicates locates the transition distribution — rather than reusing another dataset’s.
Open points before adopting it.
-
Validated on one dataset, and this is now the critical gap.
dc_tap_paper_wtc11_no_shufis the only dataset with a full six-point sweep. Inventory of what exists today:Dataset Effect sizes with power output dc_tap_paper_wtc11_no_shuf0.05 / 0.1 / 0.15 / 0.2 / 0.25 / 0.5 day0_grna200.1 / 0.15 / 0.2 dc_tap_paper_k562/dc_tap_paper_wtc110.05 / 0.1 (and both carry the size-factor shuffle) day0_grna20_no_shuffle,dc_tap_paper_k562_no_shuf,day2,day40.15 only So the held-out test cannot be repeated on a second dataset without new compute. Sweeps are needed on at least two more datasets, and the two worth doing are
dc_tap_paper_k562_no_shuf— same protocol aswtc11, different cell type, so it isolates cell type from method — and Gasperini et al., which is a different lab, protocol and scale entirely and is therefore the real test of generalization. The DC-TAP data is Ray et al.;wtc11andk562are two of its cell types, so validating only within it is close to validating within one experiment.Note the shuffled variants are not usable as references for absolute power, since the size-factor permutation paired each cell’s library size with another cell’s perturbation status. They may still be usable for checking the functional form of the curve, which is a claim about shape rather than level — worth deciding deliberately rather than by accident.
zshould come from the threshold, but did not match. Fittingzper pair onwtc11gives a median of 2.045, and the script’s--threshold-fileroute would useqnorm(1 - threshold). Where those disagree,--fit-zprofiles a single sharedzby total deviance. Worth understanding why they differ — sceptre’s test is a conditional-resampling test with a skew-normal approximation, not a Wald test, so some deviation is expected.- Keep the raw per-effect-size power values. The fitted curve is monotone by construction, so the monotonicity check above is only meaningful on unfitted numbers.
- Low-count genes may plateau below power 1, because the resampling p-value has a granularity
floor. A two-parameter version (
gamma * Phi(...)) would cover that, but then three points leave no slack for checking.deviance / dfper pair, which the script reports, is the diagnostic. - The deviance says the model is imperfect, even though it predicts well. Running the prototype
on
wtc11(fit 0.05 / 0.1 / 0.25, predict the rest) reproduces the held-out accuracy — MAE 0.0175 with--fit-z, 0.0156 withzfixed at 2.045 — but reportsdeviance / dfof 2.4–2.7 (median, 90th percentile 8.2). The earlier probit-residual check missed this because it discarded saturated points; the GLM includes them, and binomial deviance is very sensitive there. So the straight line is not the true curve at the saturated ends, while still interpolating the transition to within about 1.5× the Monte-Carlo noise. Use it for interpolation, not for extrapolation past the fitted range, and treat a highdeviance / dfas “check this pair” rather than as a verdict on the pair. - Profiling
zdid slightly worse than fixing it (0.0175 against 0.0156), so--fit-zis a diagnostic for whether the threshold and the curves agree, not the recommended default. - The MDES columns in the table above treat the measured six-point grid as truth. It is not: each point carries ±0.05, so pairs near the 0.8 boundary flip grid steps easily. Some of the 11 % disagreement is the measurement being wrong rather than the fit, which pools 300 draws.
Predicting the curve from covariates instead of measuring it
The obvious extension is to skip simulation altogether: the theory says
SE^2 ~ (1 / n_pert_cells) * (1 / mu + 1 / theta), so k = 1 / SE should be predictable from the
perturbed-cell count and the gene’s expression, both of which the pipeline already reports per pair.
Regressing the fitted k on those two, over the 6,030 wtc11 pairs with a usable fit:
log(k) = -0.605 + 0.502 * log(perturbed cells) + 0.351 * log(expression)
theory: 0.500 theory: 0 to 0.5
R^2 = 0.867 residual sd of log k = 0.243 -> k predicted to within x1.28
The perturbed-cell exponent lands on 0.502 against a theoretical 0.5, so the sqrt(n) law is
confirmed on real data. It is still not a substitute for measurement where per-pair claims are
concerned. A x1.28 error in k is about ±0.20 in power near the middle of a pair’s transition,
against 0.017 for the per-pair curve fit above — and that error is model error, not sampling noise,
so it does not shrink with more simulation and is likely concentrated in particular genes rather
spread evenly. Certifying a negative on a prediction would mis-certify a non-random subset of pairs.
Where it is the right tool:
- Design counterfactuals — the power gained from twice the cells or more gRNAs per element. Measurement cannot answer these at all.
- Aggregate statements, where ±0.2 per pair averages down over thousands of pairs.
- Choosing the effect-size grid without a pilot run, and deciding which pairs are worth simulating.
- Pairs never tested — the 1,564 that failed pairwise QC on
day0, or a planned experiment.
Note one trap in the numbers above: perturbed-cell count alone explains only 2.3 % of the variance while expression alone explains 83.7 %. That is not evidence that cell count is unimportant — its exponent is confirmed — but that it barely varies across pairs in this dataset. Causally important, empirically near-constant.
The defensible per-pair version, if wanted, is to calibrate the residual quantiles on a measured subset and widen each pair’s effect-size interval by them. That gives honest intervals, merely wider than measured ones — and quantifies what a measured sweep is buying.
What this needs before it can carry a paper
This subsection was written as a caveat on a side result. It is no longer a side result: predicting
power for pairs that were never simulated is the intended headline, because it is the only route to
power for the trans pairs a bootstrap cannot reach (an element against genes on another chromosome —
sceptre supports the test, but simulating it pair by pair is infeasible). The claim therefore rests on
the least-validated result in this document, and these are the gaps, recorded so they are not
discovered late:
- Fitted on one dataset. Cross-dataset transfer is the claim, and it is untested. Needs
dc_tap_paper_k562_no_shuf(same protocol, different cell type) and Gasperini et al. (different lab, protocol and scale) — the same two sweeps the power-curve section above needs, so one round of compute serves both. Refitlog(k) ~ log(pert_cells) + log(expression)per dataset and ask whether the coefficients transfer, not just whether each dataset fits well on its own. A model that has to be refitted per dataset is still useful but is a different, weaker claim. - A ×1.28 error in
kis ≈ ±0.20 in power mid-transition, and it is model error, not sampling noise, so it does not shrink with more replicates. It is also unlikely to be spread evenly across genes. For training labels in scE2G / ENCODE-rE2G, that means mislabelled negatives concentrated in a non-random subset of pairs — which is worse for a classifier than uniform noise. Quantify which pairs the residuals concentrate in (expression decile? dispersion? cell count?) rather than reporting the residual sd alone. - The covariate model is close to a one-covariate model on this data. Expression alone explains
83.7 % of the variance in
k, perturbed cells alone 2.3 %. Thesqrt(n)exponent is confirmed at 0.502 against a theoretical 0.500, so cell count is causally right and empirically near-constant here — but a reviewer will ask, and a dataset with real variation in perturbed-cell count is what answers it. Gasperini et al. differs enough in scale to provide that. - The
transcase needs a demonstration, not an argument. Simulate a tractable subset oftranspairs directly, then compare against what the covariate model predicts for them. Without that, the headline is an extrapolation fromcispairs to a regime where nothing has been measured — andtranspairs may differ systematically, since the gene sets are not matched to the element’s neighbourhood. - Calibrated intervals should be a contribution, not a footnote. Prediction plus honest residual-calibrated intervals is a defensible per-pair claim; a point prediction with ±0.20 is not.
Prior art to check before writing any of this up: scPower (Schmid et al.) is the work reviewers will name, and the claim that no satisfying method exists needs an actual literature search rather than an assumption.
Running the comparisons on the cluster
Setup
git clone https://github.com/EngreitzLab/element-gene-power-analysis.git
cd element-gene-power-analysis
pixi install
pixi run setup # compiles sceptre; needs a compiler and network
pixi run check-api # must print "All checks passed"
pixi run setup installs sceptre into .pixi/rlibs inside the checkout, so the whole environment is
self-contained and needs no module system. It does require outbound network access to GitHub — if the
compute nodes are offline, run setup on a login node first.
Smoke test before committing CPU-days
Three targets, 2 replicates — should finish in a couple of minutes and proves the environment works.
mkdir -p prepared splits sim
Rscript src/prepare_sim_input.R \
--sceptre-object results/sample1/sceptre_object.rds --outdir prepared/
Rscript src/split_pairs.R --pairs prepared/pairs.tsv --n-splits 280 --outdir splits/
Rscript src/run_power_simulation.R \
--sim-input prepared/sim_input.rds --sceptre-template prepared/sceptre_template.rds \
--pairs splits/split_001.tsv --grna-targets prepared/grna_targets.tsv \
--effect-size 0.15 --reps 2 --seed 20250812 --out sim/smoke.tsv
Full run as a SLURM array
prepare_sim_input.R once, then one array task per (split, effect size).
These snippets are illustrative and predate workflow/slurm_executor/, which is what actually
runs — prefer it. In particular they omit --null-precomputations, so as written they would run
the cleared configuration at ~4.3× the cost: the template’s inherited slot is now emptied by
slim_sceptre_object(), so sceptre refits every gene’s null model inside every call. Add steps 02b
and 02c and pass the bundle.
#!/usr/bin/env bash
#SBATCH --job-name=power-prepare
#SBATCH --mem=12G
#SBATCH --time=1:00:00
#SBATCH --cpus-per-task=1
pixi run Rscript src/prepare_sim_input.R \
--sceptre-object results/sample1/sceptre_object.rds \
--outdir prepared/
pixi run Rscript src/split_pairs.R \
--pairs prepared/pairs.tsv --n-splits 280 --outdir splits/
#!/usr/bin/env bash
#SBATCH --job-name=power-sim
#SBATCH --array=1-280
#SBATCH --mem=4G
#SBATCH --time=3:00:00
#SBATCH --cpus-per-task=1
#SBATCH --output=logs/sim_%A_%a.out
# Selected by position rather than by formatting the index: split_pairs.R pads the filename to
# the width of --n-splits, so 280 splits give split_001.tsv but 1000 would give split_0001.tsv.
SPLIT=$(ls splits/split_*.tsv | sed -n "${SLURM_ARRAY_TASK_ID}p")
for ES in 0.15 0.2; do
pixi run Rscript src/run_power_simulation.R \
--sim-input prepared/sim_input.rds \
--sceptre-template prepared/sceptre_template.rds \
--pairs "$SPLIT" \
--grna-targets prepared/grna_targets.tsv \
--effect-size "$ES" --reps 100 --seed 20250812 \
--out "sim/split_${SLURM_ARRAY_TASK_ID}_es${ES}.tsv"
done
Then per effect size:
for ES in 0.15 0.2; do
pixi run Rscript src/compute_power.R \
--simulations "$(ls sim/*_es${ES}.tsv | paste -sd, -)" \
--threshold-file prepared/discovery_threshold.txt \
--out "power_es${ES}.tsv"
done
pixi run Rscript src/summarize_power.R \
--power power_es0.15.tsv,power_es0.2.tsv --out power_summary.tsv
Expect roughly an hour per array task at 10 targets × 100 replicates × 2 effect sizes. Time limit is set to 3h for headroom — per-target cost varies about 1.5× with pair count.
Comparison 1 — old vs new pipeline — DONE, PASSED (job 38916341, 2026-08-13)
The refactor is validated. The old pipeline’s 2026-05-14 output was still on disk
(results/day0_grna20_no_shuffle/power_analysis/power_analysis_results_es_0.15.tsv, 34,886 pairs),
so the old environment never had to be rebuilt — which retires the CentOS 7 / pinned-linux-64-solve
risk described below. old_vs_as_is is the gating comparison, because the old pipeline inherits the
same real-data cache as_is does; comparing it against null_fit would fold in a deliberate change
of null model.
| Check | Result | |
|---|---|---|
| 1. Pair sets | identical, 34,886 shared, 0 either side | PASS |
| 2. Perturbed cells per pair | 0 of 34,886 differ, max abs difference 0 | PASS |
| 3. Mean power | 0.59293 → 0.59326, +0.00032 = 1.37 σ | PASS |
| 4. Paired sign test | 13,823 up / 13,642 down / 7,421 tied, p = 0.28 | PASS |
| 5. Per-pair agreement | Pearson r = 0.99299, mean abs 0.0304, median 0.02, max 0.26 | PASS |
| 6. Old estimate inside new Wilson interval | 81.2 % coverage | informational |
Check 2 is the strongest of these and is easy to skim past. Perturbed cells per pair is RNG-independent, so a zero difference across all 34,886 pairs proves the two implementations select identical cells for identical pairs — the entire upstream path is bit-identical, and only the simulation’s RNG stream differs. Checks 3–5 are then about Monte-Carlo agreement alone.
Check 5’s mean absolute difference of 0.0304 is not a discrepancy: at 100 replicates a single power estimate carries a standard error up to 0.05, and both sides carry one, so per-pair scatter of this size is what agreement looks like. What would have been alarming is a directional offset, and check 4 finds none — 13,823 against 13,642 is as close to a coin flip as this many pairs allow.
The old_vs_null_fit comparison “fails” check 3, and that is the correct behaviour. Mean power
differs by 0.0066 = 28.1 σ. That is not a refactor problem — it is the deliberate null-model change,
re-measured independently: null_fit − as_is was +0.0063 measured directly, null_fit − old is
+0.0066, and as_is − old is +0.0003. The three are additive and consistent, which is a useful
triangulation. Check 3 tests “is this the same estimator?” against the Monte-Carlo SE of a mean over
34,886 pairs (0.00024), so any real systematic shift blows through it however small. It is reported
and does not gate.
Reports: results/refactor/comparison/old_vs_as_is/ and .../old_vs_null_fit/.
Why it was outstanding: the upstream statistics are proven bit-identical (size factors, normalised
means, raw means: max absolute difference exactly 0), and dropping @grna_matrix was proven to leave
discovery results unchanged. What has not been checked is the simulation itself, because seeding
moved inside the replicate loop, which changes the RNG stream. So the comparison has to be
distributional rather than exact.
The old code needs the old environment, which is why the cluster is the right place: the old
envs/power_analysis.yml is a fully-pinned linux-64 solve. It cannot be solved on macOS, but
should solve on a Linux compute node.
# 1. check out the pre-refactor tree alongside the current one.
# b3cf7ab, not c7448a7: it is the tip of the old `low-mem` branch and includes the
# "Improve memory usage in power simulations" commit, so it is the version actually
# being run in practice.
#
# Sherlock's default git is 1.8.3.1, which has no `worktree` subcommand -- load a
# modern one first. Do NOT `git checkout` the old commit in the working tree instead:
# the sbatch scripts run `src/` from the repository root, so swapping branches under a
# running array silently changes the code mid-run.
ml system git/2.45.1
git worktree add ../egpa-old b3cf7ab
cd ../egpa-old
# 2. the old pipeline expects four inputs under results/<sample>/ with these exact names
mkdir -p results/sample1
cp /path/to/sceptre_object.rds results/sample1/final_sceptre_object.rds
cp /path/to/grna_groups_table.rds results/sample1/
cp /path/to/gene_grna_group_pairs.rds results/sample1/
# the fourth input has to be an .rds, so convert the discovery-results CSV:
Rscript -e 'saveRDS(read.csv("/path/to/sceptre_discovery_results.csv"),
"results/sample1/results_run_discovery_analysis.rds")'
# 3. run the old pipeline for the power results, NOT `all`
# (`all` stops at the concatenated per-replicate file)
snakemake --use-conda --conda-frontend conda --cores 8 \
results/sample1/power_analysis/power_analysis_results_es_0.15.tsv
Set num_replicates in that tree’s config/config.yml to whatever you use for the new run so the
two are comparable, and remember the old config/config.yml also has the dead reps: 20 key — it is
num_replicates that takes effect.
Then compare against the new power_es0.15.tsv. Suggested acceptance criteria:
| Check | Expectation |
|---|---|
| Pairs present | identical set |
| Per-pair power difference | centred on 0; no systematic offset (paired sign test not significant) |
| Mean power | agrees within the Monte-Carlo error of the replicate count used |
| Correlation of per-pair power | high (> 0.95 at 100 replicates) |
A systematic offset in either direction is a real discrepancy and worth chasing. Scatter across individual pairs is expected — at 100 replicates the standard error of a single estimate is up to 0.05, so differences of ±0.1 on individual pairs are normal.
Use the same --reps in both, and note the old pipeline cannot be seeded, so it produces a different
draw every run. Running it two or three times gives a sense of its own run-to-run spread, which is
the yardstick for judging the old-vs-new difference.
Comparison 2 — monotonicity at full replicate count
Power must not decrease as effect size increases. At 12 replicates on 33 pairs this held (mean power 0.356 → 0.604 → 0.838 for 0.15 / 0.25 / 0.5) with one violation of 0.08 that a two-proportion test could not distinguish from noise. Worth repeating at 100 replicates across all 32,386 pairs, where noise-driven violations should nearly vanish. A violation that survives is a bug.
Comparison 3 — timing and memory at scale
Run with --with-report-style accounting (or just sacct) and record actual per-task wall time and
MaxRSS, so the resource requests above can be tightened. The 4 GB figure comes from a laptop run of
three targets; confirm it holds for splits containing the largest targets (up to 36 pairs).
Decisions already made
Recorded so they are not relitigated:
| Decision | Rationale |
|---|---|
| Power-analysis only; no differential-expression step | the three orphan diffex scripts were unwired duplicates |
| Only the sceptre object as input | the other three files duplicate data inside it |
| No Bioconductor | SingleCellExperiment was used purely as a container |
sceptre pinned to a commit, installed by pixi run setup |
not on conda-forge/bioconda, and the pipeline reads unexported slots |
n_control_cells off by default |
measured 21–60 % power loss |
Threshold derived from @discovery_result, not alpha |
reflects the correction actually applied |
| Wilson intervals reported per pair | the normal approximation gives [0, 0] for zero successes |
Prefer more n_splits over more reps_per_chunk |
chunking replicates re-pays the per-target setup |
| local + slurm profiles only | no cloud executor needed |
The analysis question this is all for
Recorded because it determines which columns matter, and therefore how many replicates to buy.
The goal is false-negative triage: for a pair CRISPRi did not call, is the link absent, or could the experiment not have seen one? Three consequences follow, and Output explains each in full.
1. Threshold power_ci_low, never power. “This negative is biological” asserts that power was
at least 0.8. Certifying that needs 88/100 successes — or 29/30, which is why 30 replicates is
unusable for this question regardless of what the precision tables say. At 0.15 and 100 replicates,
under null_fit (power_null_fit/power_es0.15.tsv): 38.3 % of pairs certified, 12.6 % ambiguous,
49.0 % clearly underpowered (that last half is not a replicate problem — it needs more perturbed
cells or a larger effect). The as_is run gave 37.5 / 12.6 / 49.9.
2. Minimum detectable effect size is the deliverable, not six power columns. summarize_power.R
now emits min_detectable_effect_size plus a _ci_low / _ci_high bracket, where _ci_high is
derived from power_ci_low: the interval inverts, as power rises with effect size. It turns the
sweep into one sentence per pair: “we would have caught a knockdown of ≥ 25 %, so the absence of a
call rules out effects that large.” Qualifying requires clearing the threshold at that effect size
and every larger one tested; taking the first effect size that clears lets noise bias
every pair towards looking more detectable than it is.
3. Per-element power needs reframing before it can be reported. The tempting version — “this
element is well powered whatever gene you pair it with” — is not what the data say. At 0.15 only
20.9 % of the variance in per-pair power is between elements; 79 % is gene to gene within an
element, the mean within-element SD is 0.34, and element mean power correlates with
mean_pert_cells at only 0.38. A mean over the pairs an element happens to have tested is also
incomparable between elements: it inherits the expression levels of whichever genes sit nearby.
The defensible framing is conditional: per-pair power answers “could we have detected this link?”, per-element power answers “how well did we perturb this element?” — whose sufficient statistic is the perturbed-cell count, not anything about genes. Report it at a reference gene: “for a gene at median expression, element E has 0.7 power at a 15 % knockdown.”
Not implemented. The fit is cheap and needs no new simulation — power_summary.tsv already
carries mean_pert_cells and average_expression_all_cells per pair, so fitting
power ~ f(pert_cells, expression) and evaluating at reference expression is post-processing.
Simulating a reference-gene panel instead would cost ~450 CPU-h, because the per-target term
is paid whether a target carries one gene or thirty. Whoever picks this up should decide where it
lives: a new src/ script, or a section of summarize_power.R.
What does hold at element level is a floor: 21.9 % of elements (663/3,026) have no tested pair that could have reached power 0.8, so they cannot support a “regulates nothing” claim under any reading, and they should be excluded from biological interpretation rather than reported as null results. Conversely only 5 of 3,026 elements have every tested pair certified — element-wide negative claims are essentially never assertable at 100 replicates and a 15 % knockdown.
Open questions
- Two-stage replicate allocation: worth wiring in? Costed properly it is ~1,768 CPU-h per effect size against 3,430 for a uniform 400 and 858 for a uniform 100 — a 1.9× saving over uniform 400, not the 3.7× an earlier version of this document claimed. That claim came from counting replicate equivalents, which ignores the per-target term; see Choosing num_replicates. It only pays off if per-pair cutoff decisions are being made — which, per the section above, they are.
- Is the 5 % effect size worth 100 replicates? Nearly every pair is underpowered there, so the replicates buy a precise estimate of a number that is 0. Sweeping 5 % at 30 reps and spending the difference on 20–25 % would carry more information. Untested.
-
--target-overheadinsplit_pairs.R: closed, and the answer is “leave it”. It defaults to 0, so every split is balanced on pair count alone. Theas_isarray fixed the right value at the time (5.076s per target against 0.5923s per pair ⇒ ≈ 8.6 pair-equivalents), but setting it bought almost nothing atN_SPLITS=1000, and the reason this document previously gave for setting it was wrong. The gRNA reuse has since made it moot: at 1.140s per target against 0.5561s per pair the right value is ≈ 2.0 pair-equivalents, on splits that already hold 34–36 pairs. There is nothing left to correct for.Measured on the real splits, and on job
38849611’s ownsacctrecord:Predicted tail task Predicted floor max/mean --target-overhead 0(what ran)68.4 min 43.0 min 1.139× --target-overhead 8.667.4 min 58.0 min 1.123× Simulating the same LPT bin packing
split_pairs.Ruses reproduces the on-disk splits exactly at overhead 0, so the comparison is like for like. The overhead raises the floor a lot (43 → 58 min) and moves the tail by one minute — and the tail is what determines when the array finishes. At 1,000 splits each task holds only 1–4 targets, so there is little imbalance left to reclaim.This refutes the previous revision of this point, which blamed the observed 35–110 minute spread on pair-only balancing. It is not that: the cost model predicts a 43–68 minute range, and regressing actual task time on predicted gives r = 0.373, r² = 0.139 — split composition explains 14 % of the variance. The model is unbiased on the mean (36.02s predicted against 35.97s actual per replicate), so it is not simply wrong. What explains the spread is node heterogeneity: across the 229 nodes the array landed on, the slowest node’s mean is 2.73× the fastest (55.8s against 20.5s per replicate). There was no preemption to blame (999 COMPLETED, 1 FAILED). Set
--target-overhead 8.6ifN_SPLITSever drops far enough that splits hold ten or more targets; do not re-split an array for it. - ODM / out-of-core support is designed for but not implemented. The seam is
get_response_matrix()inlib/sceptre_io.R, which currently errors explicitly on anodm. The hard part is that poscounts size factors need a per-cell median over genes — a column-wise reduction — while anodmis row-accessible.