Skip to content

Port the pileup, cut-site tables and the memory planner — Closes #3 - #8

Draft
conradbzura wants to merge 3 commits into
quadrat-2-package-skeletonfrom
quadrat-3-pileup
Draft

conradbzura wants to merge 3 commits into
quadrat-2-package-skeletonfrom
quadrat-3-pileup

Conversation

@conradbzura

Copy link
Copy Markdown
Contributor

Summary

Port cherimoya's pileup across eight modules, split along seams that already existed in the 2,652-line module it came from. Output is byte-identical: the same two alignments through cherimoya and through quadrat produce the same Parquet, per contig, at one contig batch and at three, manifests included.

The split resolves a cycle rather than imposing one. Upstream, the planner and the SQL layer import from each other inside function bodies because the module graph would not otherwise admit them. Here plan.py is a leaf, sql.py and alignments.py depend on it, run.py depends on plan, and the four lazy imports that existed to tolerate the cycle are gone — which is the test of whether the seam was real.

Based on quadrat-2-package-skeleton rather than main, since it builds on the packaging in #7. GitHub retargets this to main when that merges.

Closes #3

Proposed changes

Module split

alignments.py reads contigs, indexes and contig batches; tables.py owns the cut-site format and its atomic writer; pileup/sql.py holds the aggregate; pileup/plan.py the memory model; pileup/run.py the backends; pileup/__init__.py the entry point. _engine.py and reference.py carry the session and the reference reader.

Renames that are behaviour

Rename the polars-bio table names and the cache variable to a quadrat_ prefix. The session is process-global, so both packages must be importable into one process during the transition. That same globalness is why a worker runs one pileup at a time, and the reasoning travels with the names.

What deliberately does not change

Keep CUT_SITE_SUFFIX and MANIFEST_VERSION as cherimoya's, so a table either package writes is readable by the other. Keep every heavy import inside the function body that needs it: import quadrat pulls in none of polars, polars-bio, torch, giql or pyfaidx.

Deferred backend

Ship BACKENDS as serial and process. The wool backend dispatches to workers on other machines and is the only part of the pileup needing a dependency outside this package; asking for it raises NotImplementedError naming the alternative, because a caller migrating a working configuration needs to know it is deferred rather than that it never existed.

Fix carried from #7

write_random_bam placed every read on the first contig — the branch meant to spread them indexed the contig list by a value that was zero whenever that branch was taken. The builder's own test only checked sortedness, so a one-contig corpus looked correct; the parity run exposed it. Add the assertion that was missing.

Test cases

43 tests: 34 ported from cherimoya's test_pileup.py, 8 from its test_giql_io.py, 1 new. Summarized by area rather than per test.

# Test Suite Given When Then Coverage Target
1 test_pileup A machine of pinned cores and memory plan_pileup is called across input sizes Returns worker and batch counts the memory model admits Memory envelope
2 test_pileup A job larger than the scan cap The planner solves its batch count Splits it however much memory is free Scan cap
3 test_pileup A lopsided job set pileup_many assigns batch counts Splits only the large jobs, not the mean Per-job floor
4 test_pileup The same jobs under serial and process Both backends run Write byte-identical tables in the same order Backend equivalence
5 test_pileup An unrecognised backend name pileup_many is called Raises ValueError naming the backends that exist Backend validation
6 test_pileup A request for the wool backend pileup_many is called Raises NotImplementedError naming the alternative Deferred backend
7 test_pileup_io A BAM with deletions, soft clips and skips It is read through the BAM provider Coordinates equal pysam's Provider parity
8 test_pileup_io Two alignments and a reference pileup_alignments runs Matches the bam2bw oracle, drops unmapped and off-reference reads Pileup semantics
9 test_pileup_io A scale factor and a stranded request The pileup runs Applies the scale once and splits strands Aggregate options
10 test_pileup_io A cut-site directory cut_site_contigs lists it Answers from filenames without reading rows Contig listing
11 test_pileup_io The pileup query plan EXPLAIN is inspected Places the join above the aggregate Overflow fix
12 test_builders Sixty reads over three contigs write_random_bam places them Reaches every contig, most on the first Regression from #7

@conradbzura conradbzura self-assigned this Oct 23, 2026
Somewhere for the ported data path to land. No logic yet, so
that a later failure is attributable to the port rather than to the
scaffolding it lands on.

Follows abdenlab/giql, the nearest sibling and quadrat's own
dependency: src layout, hatchling, ruff at line-length 89, four-space
indentation, PEP 735 dependency groups. Python floor is 3.11 rather
than cherimoya's 3.10, because polars-bio, polars and giql are all
gated there and the pileup is not an optional part of what this
package is for.

Runtime dependencies shear from cherimoya's sixteen to six, with the
pileup engine behind a `pileup` extra. Every one of those imports is
already lazy inside a function body upstream, so a caller that streams
windows over tables it already has never pays for them. Two defects in
cherimoya's manifest are corrected rather than carried: pyfaidx is
imported by production code but declared only in a dev group, and
cherimoya.workers is missing from the explicit package list, so it
never ships in a built distribution. Hatchling's src discovery makes
the second unrepeatable.

The ruff select is pinned rather than left to the default. Ruff 0.17
enables UP, SIM, FURB and RUF by default, and UP032 alone fires on
cherimoya's `str.format` style throughout -- so the default would
demand a style migration inside the change that moves the code, which
is the one thing that makes a port unreviewable.

giql resolves from the public commit at the tip of its cherimoya-mvp
branch. The datafusion-bio target is not on PyPI and cherimoya pins a
path outside its own tree, which a published package cannot inherit.
That commit's library code is identical to what cherimoya builds
against; the only delta is benchmark material outside src.

The fixture layer is the substantive half. Cherimoya has no data-path
fixtures in conftest -- every one is module-local, and the FASTA, BAM
and BED quintet is duplicated five ways differing only in chromosome
sizes and window geometry. Porting that duplication would multiply it,
so the builders take the geometry as arguments and the five sets
collapse onto one factory. Cut-site builders arrive with the writer
they need, in the pileup slice.

The cache directory is pointed at a fresh temporary directory at import
time, before anything imports the package, because default_cache_dir
reads it per call: set later, the first reader would still see the
user's own cache, and a surviving entry would satisfy a test written to
exercise a cold build.
Review roles, authoring guides and accumulated review documents are
local working state, untracked here as they are in the sibling
projects. The review history is versioned by its own repository under
.sdlc; the guides are config rather than a project artifact.
Ports cherimoya's pileup across eight modules, split along seams that
already existed in the 2,652-line module it came from. Output is
byte-identical: the same two alignments through cherimoya and through
quadrat produce the same Parquet, per contig, at one contig batch and
at three, manifests included.

The split resolves a cycle rather than imposing one. Upstream, the
planner and the SQL layer import from each other inside function
bodies because the module graph would not otherwise admit them. Here
plan.py is a leaf, sql.py and alignments.py depend on it, run.py
depends on plan, and the four lazy imports that existed to tolerate
the cycle are gone -- which is the test of whether the seam was real.

Three names had to change because the polars-bio session is
process-global. Its table names carry a quadrat_ prefix rather than a
cherimoya_ one, and the cache directory variable follows, so both
packages can be imported into one process during the transition. That
same globalness is why a worker runs one pileup at a time, and the
reasoning travels with the names.

Two things deliberately do not change. CUT_SITE_SUFFIX and
MANIFEST_VERSION are cherimoya's, so a table either package writes is
readable by the other; the alternative is re-piling every corpus to
move between them. And `import quadrat` still pulls in none of polars,
polars-bio, torch, giql or pyfaidx -- every heavy import stayed inside
the function body that needs it, which is what makes the pileup extra
optional rather than nominal.

The wool backend is deferred rather than ported. It dispatches to
workers on other machines and is the only part of the pileup needing a
dependency outside this package. BACKENDS ships as serial and process,
and asking for wool raises NotImplementedError naming the alternative,
because a caller migrating a working configuration needs to know it is
deferred and not that it never existed.

Tests: 34 of cherimoya's 38 pileup tests and 8 of its 30 giql_io
tests. The four dropped from each drive the cherimoya CLI. The other
18 giql_io tests cover locus loading and extraction and belong to a
later slice, not here -- the earlier estimate of 26 counted tests
leaving cherimoya rather than tests arriving in this module.

Two ported tests needed adjusting, both for the two-backend contract
rather than for the port: one asserted on an error message listing
three backends, and one iterated a hardcoded tuple of them, which now
reads BACKENDS so the wool backend is picked up when it lands.

Also fixes a defect in the test builders from the previous commit.
write_random_bam placed every read on the first contig, because the
branch meant to spread them indexed the contig list by a value that
was zero whenever that branch was taken. It survived review because
the builder's own test only checked that reads came back sorted, so a
corpus confined to one contig looked correct. The parity run found it:
the pileup wrote one per-contig table where it should have written
three. There is now a test asserting reads reach every contig, which
is the assertion that was missing.
@conradbzura
conradbzura force-pushed the quadrat-2-package-skeleton branch from b7dd054 to f8edaff Compare October 23, 2026 21:36
@conradbzura
conradbzura force-pushed the quadrat-2-package-skeleton branch 6 times, most recently from 142c822 to f00a79e Compare October 28, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant