Development
Environment
pixi manages everything except sceptre.
pixi install # runtime environment from pixi.lock
pixi run setup # install sceptre from the pinned commit
pixi run check-api # verify the pin against the internals this pipeline uses
pixi run lint # formatting and convention checks (read-only)
pixi run install-hooks
Three environments, so the one every task activates stays small:
| Environment | Contains | Why separate |
|---|---|---|
| default | r-base, r-optparse, nextflow, and the packages sceptre reaches on our code path (r-matrix, r-rcpp, r-dplyr, r-data.table, r-purrr, r-crayon, r-parallelly, r-withr) |
activated by every task |
build |
r-remotes, compilers, r-ggplot2, r-cowplot, r-scales, r-bh |
only needed to compile sceptre |
dev |
r-testthat |
only needed to run tests |
Why ggplot2 is a build-only dependency
R CMD INSTALL enforces a package’s DESCRIPTION Imports even for packages absent from its
NAMESPACE. sceptre’s NAMESPACE imports only Matrix and Rcpp, but its DESCRIPTION lists
ggplot2, cowplot, scales, dplyr, data.table, purrr and crayon, so all of them must be
present to build it. At runtime only the ones actually called matter — and ggplot2, cowplot and
scales appear solely in plotting_functions.R, qq_plot_helpers.R and
write_outputs_to_directory(), none of which this pipeline calls. Hence the split.
purrr, by contrast, is needed at runtime: purrr::flatten() is called in the precomputation
path that run_discovery_analysis() goes through.
BH is build-only for a different reason again: sceptre declares it under LinkingTo, so it is a
header-only C++ dependency used to compile and never loaded at all. src/audit_dependencies.R
reports all three categories separately, and is the check to run before trusting this table.
No Bioconductor
The pipeline once used SingleCellExperiment, but only as a container — a per-gene table, a per-cell
table, two sparse perturbation matrices, and subsetting. Nothing touched rowRanges or any genomic
range. lib/sim_input.R replaces it with a plain list, which removes sixteen Bioconductor
packages and leaves nextflow as the only bioconda dependency.
It also avoids a hard setup failure: bioconductor-genomeinfodbdata installs its data through a
conda post-link script, which pixi skips by default, so GenomeInfoDb — and therefore
SingleCellExperiment — could not be loaded at all without every user first running
pixi config set --local run-post-link-scripts insecure.
sceptre is pinned, and why that matters
sceptre is not on conda-forge or bioconda, so it is installed from a pinned commit
(SCEPTRE_REF / SCEPTRE_SHA in pixi.toml) into a project-local library at .pixi/rlibs.
The pin is not housekeeping. This pipeline reaches into sceptre’s unexported S4 slots: it swaps the response matrix for simulated counts, narrows the discovery pairs to one target at a time, and reads the cached negative-binomial precomputations. None of that is a documented interface, so a sceptre release could change it with no deprecation warning and the pipeline would keep running while producing wrong numbers.
src/check_sceptre_api.R is the guard. It asserts the presence of every slot the pipeline reads:
@response_matrix, @grna_matrix, @covariate_data_frame, @covariate_matrix,
@grna_target_data_frame, @discovery_pairs_with_info, @initial_grna_assignment_list,
@grna_assignments, @cells_in_use, @response_precomputations, @functs_called
Run it after any change to the pin. All slot access is confined to lib/sceptre_io.R, so a
sceptre upgrade breaks one file rather than five.
Two behaviours of sceptre worth knowing before touching the simulation:
load_row()dispatches onodmanddgRMatrixwith noelsebranch. Handing it a dense matrix returnsNULLsilently instead of erroring. The simulated counts must be converted todgRMatrixbefore being assigned to@response_matrix.- With
run_permutations = FALSEthe conditional-randomisation path readsresponse_matrix@j/@p/@xdirectly, which only adgRMatrixhas.
sceptre is also patched
The installed sceptre is the pinned commit plus the patches in patches/, applied by
src/install_sceptre.R before it compiles. Each patch adds an optional argument with a NULL
default, so unpatched sceptre behaves identically and nothing in patches/ changes any result.
| Patch | What it adds |
|---|---|
0001-reuse-grna-precomputation.patch |
grna_precomputations on run_discovery_analysis() and run_power_check(), and an exported compute_grna_precomputations(). Lets the caller supply the gRNA null model instead of having sceptre refit it once per call. |
Why a patch rather than a wrapper: on the CRT path sceptre regresses perturbation status on the
covariates and draws its synthetic assignments from the fitted probabilities. That fit depends only
on the gRNA-to-cell assignments and the covariate matrix, never on the counts, so it is identical
for all --reps replicates of a target. But unlike @response_precomputations there was no slot to
hand it back in through — fitted_probabilities is a local variable in
crt_glm_factored_out() — so reusing it needs a signature change.
The cache holds regression coefficients, not per-cell fitted probabilities, and each entry
records the n_cells it was fitted over. Both choices matter:
- Fitted values are one double per cell. Coefficients are
ncol(covariate_matrix)doubles, and sceptre reconstructs the probabilities from them withbinomial()$linkinv(drop(X %*% coefs)), which is bit-identical —glm.fit()computes thefitted.valuesit returns exactly that way from the vector it returns ascoefficients, with a zero offset here. n_cellsis what makes the guard work. The two CRT workhorses fit on different cell sets but the same covariates, so the coefficient vector is the same length in both and its length alone cannot tell a cache built for one from a cache built for the other.
--no-grna-precomp-reuse on run_power_simulation.R turns the reuse off. Reuse is exact, so the
flag exists to demonstrate that rather than to work around it —
workflow/slurm_executor/10_grna_precomp_equivalence.sbatch runs one split both ways and compares
the outputs byte for byte.
Three things to know:
- The patches are part of the pin.
src/install_sceptre.Rrefuses to install ifpatches/is empty, andsrc/check_sceptre_api.Rasserts thatcompute_grna_precomputations()is exported and thatrun_discovery_analysis()acceptsgrna_precomputations. Without that second check an unpatched sceptre would fail only once the simulation reached its firstrun_discovery_analysis()call, long afterprepare_sim_input.Randsplit_pairs.Rhad run. - Bumping
SCEPTRE_SHAmeans regenerating the patches. They are applied with no fuzz and no offset search (lib/apply_patch.R), so a hunk that no longer matches at the exact line it claims is a hard error rather than a hunk relocated to somewhere it happens to fit. patch(1)is not used. Everything runs inside a stockubuntu:22.04container, which ships neitherpatchnorgit, solib/apply_patch.Rapplies the diff in R.
The patch stays local. Upstreaming it to Katsevich-Lab/sceptre was considered and dropped: the
patch is small, pinned to a known SHA, asserted by check_sceptre_api.R, and re-applied
automatically on install, so carrying it costs little — whereas a PR would mean tracking review on
someone else’s schedule while the pin moves underneath it. Nothing is implemented upstream and
nothing is planned.
Repository layout
src/ standalone executables, one per pipeline step
lib/ shared code, sourced by a path relative to the calling script
cli.R argument parsing, TSV I/O, seeding
sim_input.R the simulation-input container and its accessors
pert_input.R per-target cell selection
simulate.R count simulation and guide-level effect sizes
sceptre_io.R the only file that touches sceptre internals
apply_patch.R unified-diff applier, for patching sceptre at install time
patches/ local patches to the pinned sceptre, applied by install_sceptre.R
config/config.yml pipeline parameters
assets/samplesheet.csv example samplesheet
docs/ this documentation (published to GitHub Pages)
.githooks/pre-commit formatting and convention checks
lib/*.R is sourced via a path resolved from --file= in commandArgs(), so every script works
identically whether run directly or staged onto PATH by a workflow engine.
Conventions, enforced by the hook
pixi run install-hooks points core.hooksPath at .githooks. On commit, staged files are
auto-fixed for:
- CRLF and lone CR → LF
- tabs → spaces, at the width conventional for the language: 2 for R and YAML, 4 otherwise
(source files only;
.tsv/.csvare data andMakefileneeds tabs) - trailing whitespace
- missing final newline
and rejected for things that cannot be fixed without changing meaning:
- any file over 512 KB — pipeline inputs and outputs belong outside git;
results/and*.rdsare gitignored, and this is the backstop - filenames with uppercase in the stem (the extension is exempt, so
compute_power.Ris fine) - camelCase or PascalCase R identifiers
The identifier check deliberately allows SCREAMING_SNAKE constants (PERT_LEVELS) and dotted S3
methods (print.sim_input), both of which are correct R style.
pixi run lint runs the same checks across every tracked file but is read-only: it reports what
it would fix and exits non-zero, so it is safe in CI or against a dirty working tree. Use
./.githooks/pre-commit --all --fix to actually apply the whitespace fixes repo-wide.
R code is indented with 2 spaces, following tidyverse and Google R style. YAML uses 2 as well (and forbids tabs outright); shell, Groovy/Nextflow, Python and JSON use 4. The hook expands tabs to the matching width so a stray tab does not end up fighting the surrounding style, but it does not reindent existing code.
Documentation
docs/ is published to GitHub Pages by .github/workflows/pages.yml. Pages are ordinary markdown
with title and nav_order front matter.
The theme is pinned to just-the-docs@v0.3.3 because GitHub’s jekyll-build-pages action uses the
github-pages gem (Jekyll 3.9); just-the-docs v0.4+ requires Jekyll 4 and will not build. If the
Pages build ever breaks, replacing remote_theme with theme: jekyll-theme-primer in
docs/_config.yml is a zero-dependency fallback.
Known gaps
A fuller account, with the commands to run the outstanding comparisons on a cluster, is in Status and handoff.
- Workflow orchestration. The five steps are complete and run standalone; a Nextflow workflow
with a SLURM profile is not yet in the repository.
config/config.ymlalready carries the parameters it will consume. - Two-stage replicate allocation is documented but not orchestrated. The scripts support it
today via
--rep-offset. - Equivalence with the pre-refactor pipeline is proven for the upstream statistics — size
factors, normalised means and raw means are bit-identical, and dropping
@grna_matrixwas verified to leave discovery results unchanged — but the simulation itself has not been compared draw-for-draw against the old code. Seeding moved inside the replicate loop, so that comparison has to be distributional rather than exact. - The legacy
R/directory is still present so the old Snakemake pipeline remains runnable for comparison.pixi run lintreports its non-conforming identifiers; both go away when it is removed.