v0.14.0
Build / Tooling
-
CI audit: duplicate compile passes removed, cache churn stopped. Measured
per-job on a warm run (PR #252) and a cold one (a Dependabot lockfile bump),
then cut by what the numbers showed rather than by what looked redundant.-
checkis gone;clippycovers it.cargo clippy --workspace --all-targetsruns the same rustc front-end over the same unit graph as
cargo check --workspace --all-targetsand then adds lints, so a green
clippy already implied a green check. The workspace was being compiled
twice per PR for no additional signal. The same pair ran inrelease.yml's
macOS gate, where it cost 10x — GitHub bills macOS minutes at ten times
Linux. -
Doctests moved into the
testjob. nextest does not run doctests, but
cargo test --docneeds the same toolchain, profile and unit graph that
nextest has just built. As its own job it rebuilt the whole dependency
graph from scratch — 406 s cold, against roughly 2 s of actual doctest
execution — and held one of the largest caches in the repo. Sharing the
target directory makes it nearly free. -
The POD5 compat suite stops building a shipping artefact. It was
cargo build --release, i.e. fat LTO andcodegen-units = 1, to produce a
binary that round-trips small fixtures. That made it the longest job in the
workflow (316 s warm, 480 s cold) and therefore the critical path of every
PR. It now builds the newci-binprofile —opt-level = 3kept,
whole-program optimisation and single-threaded codegen dropped — and points
the suite at it through theESCPOD_BINoverride the harness already had. -
Coverage runs the instrumented suite once instead of twice. The job
rancargo llvm-cov nextestin full for lcov and then again in full for
HTML. It now runs once with--no-reportand renders lcov, the threshold
summary and HTML from that single set of profiles. -
Two feature builds dropped as duplicates. The
featuresjob exists
because "the default jobs never compile the opt-in features" — but
cnn-detectandcrf-decodeare both in escapepod-cli's defaultcli
feature, so workspace feature unification already compiled escapepod-demux
with them in the ordinaryclippyjob. The genuinely opt-in ones (gpu,
cnn-gpu, crf-gpu, models-download) are untouched. -
PR runs no longer write to the Actions cache. The repo held 11 GB
across 34 entries against GitHub's 10 GB per-repository ceiling, so it sat
in permanent LRU eviction — which is why nominally warm jobs still showed
cold timings. EverySwatinem/rust-cachestep now carries
save-if: github.ref == 'refs/heads/main', so PRs restore from main's
cache but never add to it, and cache entries stop multiplying per branch. -
Prose-only changes no longer start a Rust build.
paths-ignoreon
docs/**,**/*.md,LICENSE*and.gitignore. A commit touching both
prose and code still runs everything —paths-ignoresuppresses a run only
when every changed path matches — and with no branch protection onmain
there are no required checks for a skipped run to block.
-
Fixed
-
A targeted lookup no longer scans the whole reads table when there is no
.p5ssidecar (#251).reads_by_ids,find_signal_rows_by_idsand
find_signal_rows_with_calibration_by_idschose between an indexed path and
a full scan with ahas_index()helper that answered "is there a sidecar on
disk?" — a different question from "can I use the index?", and one with a
different answer whenever a sidecar is absent, which is the common case. So
every call scanned the file, and none of them built the index that would have
made the next call a seek, even thoughread_index()was already there,
self-caching, and cheaper than the scan being chosen instead.The fallback was never the cheap option. The index is a scan, projected to
theread_idcolumn, so building it moves strictly fewer bytes than one
execution of the path it declined — against 22 columns forreads_by_ids,
2 and 4 for the signal lookups — and it is then cached for every later call.
The early exit that justified the scan ("stops once all targets are found")
does not fire in the access pattern that matters: targets arrive in BAM
order, unrelated to POD5 storage order, so the last one sits near EOF and the
scan runs to the end of the file. Per call. Downstream this cost
rnabioco/leechroughly 10–80x on data preparation, silently; on a 145 GB
merged POD5 one call for 1000 ids had not returned after 13.5 minutes.All three entry points now go through
read_index(). The scan variants are
gone rather than kept behind a size threshold: what decides whether a scan
could win is where the targets land, not how many there are, so a threshold
ontarget_ids.len()cannot detect the one case it would be for.
Changed
-
autoindex_max()moved from the Python bindings intoescapepod-pod5,
where the decision it encodes actually lives (#251). It was reachable only
from Python — two call sites there warmed the index on context-manager entry
to route around the scan described above — so no Rust caller could reach the
policy, and the workaround had to be written again in every consumer.
ESCAPEPOD_AUTOINDEX_MAXand the 5,000,000-read default are unchanged.Its meaning is now narrower and honest: it gates speculative indexing only.
Entering a Python reader as a context manager still checks it, because that
is a guess that random access is coming and a large file that is only
iterated should not pay for an index nobody asked for. A caller that has
actually asked for random access always gets an index, whatever the file
size — above the threshold the build is reported atwarnnaming
escpod index, not traded for a scan. It was never a memory guard in any
case: loading a.p5ssidecar has always built the same in-memory entry
table with no cap at all, so the same file with a sidecar already holds what
the threshold claimed to prevent. -
Dependency bumps (lockfile only, no behavior change): the Arrow ecosystem
arrow+parquet59.1 → 59.2 and thetract-*stack 0.23.4 → 0.23.5,
plusbit-vec0.9 → 0.11 andbitflags0.19.8 → 0.19.9 (#250).
Added
-
escapepod-pod5can log. The format crate — the layer every other one
sits on — had notracingdependency at all, which is why a per-call rescan
of a 145 GB file was indistinguishable from slow I/O and cost a day of
profiling to find. Building a read index now says that it is happening, why
(no sidecar), and what it cost (reads, batches, elapsed). -
A
reads_by_idsgroup inio_hot_paths, covering the three arms that
matter: index loaded from a sidecar, index built on the first call, and the
warm steady state. Its doc states the limit of what it can prove — at fixture
scale a scan and an indexed seek cost the same, so the guard against taking
the wrong path is the invariant test that asserts the index is built exactly
once for two lookups, not the benchmark.