Roadmap
August 14, 2026 · View on GitHub
checkout, refine and assemble all work. Implemented: the .mig intermediate format (reader, writer, range partitioning, CRC-checked
blocks, provenance and quality-calibration in the header), FASTQ IO for plain and gzipped input
with strict validation, barcode packing/unpacking and the IUPAC/Phred primitives, the pybind11
module, the read simulator with ground truth, CI (C++ on ubuntu+macos, Python 3.10/3.12 matrix,
pinned ruff), sphinx docs with a zero-warning gate, and the PyPI publish workflow.
migec checkout works: degenerate barcode patterns in the MIGEC dialect read verbatim from
published barcode tables, quality-aware log-likelihood acceptance, sample assignment with an
ambiguity verdict distinct from "unmatched", UMI extraction from non-contiguous runs, trimming of
adapter/tag/UMI, barcode transfer into SAM-style FASTQ headers, the power-of-two coverage
histogram, per-position base composition with Shannon entropy and information content, collision
entropy and effective barcode length, data-driven UMI error-rate estimation, and count correction
with a sequencing/polymerase/independent-molecule mixture plus the collision-corrected molecule
count. scripts/spikein_ratio.py computes the published spike-in validation metric, and
scripts/compare_calib.py scores UMI grouping against Calib by adjusted Rand index with splitting
and merging reported separately.
Nothing in the pipeline scales with the library any more. checkout's counters and refine's
barcode table both range-partition themselves past a byte budget, and correction follows both into
the partition in two passes with the key rotated.
Throughput and footprint: every stage threads and every stage is byte-identical at any -t --
checkout 1.55 M reads/s end to end (1.70 M matching), refine 1.55 M, assemble 2.47 M, all at 16
threads; ~22 bytes per distinct UMI against a hash map's ~48. Verified under the thread sanitizer
as well as by comparison at 1..16 threads.
The UMI counters range-partition themselves past a 1 GB budget, and correction follows them into
the partition in two passes with the key rotated, so nothing in checkout scales with the library
any more. checkout --mig writes the reads into that same partition, which is the pass assemble
otherwise builds for itself: 1.16 s -> 0.98 s end to end on 500 k reads for identical molecules.
QC: twenty gnuplot panels over the tables the stages write, including the four figures a user
already knows how to read -- Cell Ranger's barcode rank plot on unique UMIs, the MIG size spectrum
with molecules and reads, the rank/Zipf curve, and consensus quality as a box over an exact
(depth, quality) grid.
Milestones are ordered by risk, not by pipeline order: the consensus quality model is the scientific claim and is validated before any throughput work.
What is left, in the order it should be done
Everything below is either open or half-open. Nothing else on this page is.
| # | item | milestone | why it is next |
|---|---|---|---|
| 1 | The RT-vs-first-cycle-PCR split inside the floor | M3 | the sequencing/pre-amplification split now has a standard — the deep-MIG consensus residual, which is what --pre-amp-error auto fits (docs/quality_floor.rst). What is still unsplit is what MADE the floor, and that needs two chemistries to compare rather than a better estimator: the same template through an RT protocol and through a DNA one. Data, not code |
| 2 | The rest of the published comparisons | M5 | MIGEC v1, MAGERI, UMIErrorCorrect, UMI-tools, fgbio and Cell Ranger are done and scored (docs/postprocessing.rst, docs/validation.rst, docs/grouping.rst, docs/single_cell.rst). Open: UMI-VarCal, which cannot run on the certified arms (single-end). This is what the version number is waiting on, not the code |
| 3 | The last two rows of the ctDNA table | M5 | the ground truth is found and scored: PRJNA788522 / PRJNA507366, certified VAF, real 12 nt inline UMI. Done: LoFreq and Mutect2 on the identical consensus BAM, a MAPQ 20 floor, adapter trimming, and UMIErrorCorrect end to end (assets/ctdna_callers.tsv). Running: the no-consensus baseline -- same reads, same aligner, same callers, reads instead of molecules -- and UMI-VarCal |
| 4 | Bit-parallel matcher | M2 | last, deliberately: the scan is O(offsets x pattern) and is not the bottleneck. It goes in when a benchmark says so |
Nothing is blocked on data any more. Britanova et al ageing (bulk TCR, shallow -- the real
1-3 reads/UMI dataset) has been run: one HiSeq lane, ten donors, 149,588,907 read pairs at a 16 nt
UMI and 2.44-2.57 reads per UMI, in assets/shallow_repertoire.tsv and docs/validation.rst. The
ctDNA ground truth was found rather than built, by screening SRA read structure instead of
trusting the published claim that none exists (scripts/sra_fetch.py probe).
M0 — skeleton, format, simulator
- Archive the Groovy implementation on
legacy-v1/v1-final, start master fresh -
.migformat frozen indocs/formats.rst, with a round-trip and truncation test - FASTQ reader/writer; a record straddling a buffer refill is covered by a test
- Read simulator with truth files, and tests for the simulator itself
- CMake + scikit-build-core + CI + docs shell
- Push to origin
X — experiments that must precede the milestones they inform
- X1 — read-start dispersion within
(CB,UMI)on one 10x run. Done, 2026-08-13, onpbmc_1k_v3at MALAT1/ACTB/B2M — 8,326 multi-read groups. The co-terminal assumption is false: 7.8% of groups overall, and 0.3% of those with ≥6 reads, so what co-terminality there is is a small-group coincidence rather than a property of the chemistry. 92% of groups are wider than one 91 nt read. But 72.7% still form a single overlap component (rising to 81.3% at ≥6 reads), so partitioning by overlap first reduces three quarters of the work to the ungapped problem MIGEC already solves — and the remaining 27.3% is why the partition is mandatory: a single consensus over them asserts sequence across a gap no read covers. Also: only 1.5% of groups hold more than one read at this depth, so for shallow 3' GEX the UMI buys counting, not error correction. Written up indocs/fragmented.rst, script inscripts/read_start_dispersion.py. - X2 — emitted-quality calibration on a clonal control, stratified by MIG size. Done,
2026-08-13, on
SRR1763769(2.12 M reads, HIV-1 Primer ID). The floor is of order 1e-4, not 1e-6: 1.54e-4 [1.36e-4, 1.74e-4] at MIGs of ≥80 reads, so no emitted quality above Q40 is supportable by default, and a blanket 1e-6 is excluded for an RT protocol by two orders of magnitude. Matches the ~1 in 10,000 the source paper reports (doi:10.1128/JVI.00522-15). Note: The curve is still declining at 80 reads, so this is an upper bound: the 9 nt Primer ID puts the library at 49.6% occupancy andcheckoutflags itsaturated, so collided MIGs contribute mismatches counted here as error. Not thep_floor + a/cleast-squares fit the plan specified — that model is wrong for a majority vote and returned a negative probability on simulated data.docs/quality_floor.rst,scripts/quality_floor.py. - X3 — three permutation nulls on one deep real dataset. Done, 2026-08-13, on
SRR1763769— 125,369 distinct 9 nt barcodes at 47.8% occupancy. (1) Position independence holds to ~1% of the collision rate. Tested as a distribution — JSD against the product measureq(u) = Π_j p_j(u_j), floored by a same-size draw fromq— not by one functional. Invisible on all distinct barcodes (saturation), clear at reads ≥ 2 (z up to 33), and entirely nearest-neighbour: every adjacent pair positive, every distant pair zero. Cause measured: 0.55% of reads carry a barcode one base short, a coupling that did not fire, which frameshifts everything after it.Π_j m_jstays; the 1.86x collision excess is the read threshold. Note: The first version reported 1.04x and all of it was artefact — N as a fifth base, and the plug-inΣ p̂²whose bias grows with k. (2) 97% of distance-1 pairs are chance (844,243 observed, 817,358 under a column shuffle), and a size-preserving count shuffle over the fixed graph finds ~18,000 genuine error children, plateauing from a count ratio of 5 upward. The permutation background puts the barcode error at 1.4e-3, 0.70x of the Phred + polymerase prediction, againstcheckout's analytic 8.0e-4 at 0.39x — M3 takes the permuted background. (3) The split threshold is 8.68, not 2.00: a curveball randomisation preserving both margins of the reads x positions minor-allele matrix puts the 1% false-positive point 19x above the nominalp < 0.01, because a low-quality read carries minor bases at many positions at once and mimics a linked subclone. Note: bootstrap 95% CI [8.42, 9.14]** over 82,800 randomisations; a tenth as many gave 9.61 and 11.66, so the interval is the number. (4) Shallow libraries (1-3 reads/UMI) are a separate regime and three of these results do not transfer to them — written up rather than quoted.docs/nulls.rst,scripts/permutation_nulls.py.
M1 — assemble, the consensus and quality model
- Grouping on the whole barcode — sample + cell + UMI. A UMI repeats across cells and
samples by design; the sort key is
(cell, umi, src_index)and the range partition is on the cell whenever there is one - Range partition into
.migbuckets, one bucket resident: 531 k reads/s, 121 MB at 16 buckets against 203 MB at one, output identical whatever the bucket count - Contig assembly (
--contig): seed placement, union-find overlap components, one consensus per component, never bridged across a gap. This is one molecule's fragments only — full-length receptor assembly and doublet filtering are arda's job - Column log-likelihood posterior:
LL[j][b] = Σ_i (r==b ? log(1−e) : log(e/3)) - Sub-clustering by linkage, not by count of polymorphic sites. Threshold 8.68 (
-log10 p, two-sided, Bonferroni'd within the MIG) from X3's false-positive curve — not the nominal 2.00, which over-calls by 19x. Note: It implies a minimum group size: the strongest evidence a pair of columns can carry islog10 C(n, n/2), so a 50/50 split needs ~34 reads to clear it - Quality floor added, not compared:
Q = −10 log10(p_cons + p_floor), default 1e-4 from X2 and from 10x's own figure, so nothing above Q40 is emitted unless--rt-errornames a higher-fidelity chemistry (medium1e-5,high1e-6) - The birthday arithmetic re-run on the barcodes assemble saw:
expected_molecules_per_groupsays how many molecules a group holds when the UMI is short by design, and contig mode warns when that makes contigs untrustworthy - Shallow libraries (1-3 reads/UMI) run, report the coverage histogram, threshold nothing, and say that the UMI is buying counting rather than error correction. Benchmarked as the memory-hostile shape: 1,179,549 reads/s at 1.02 reads/UMI, 282 B resident per distinct barcode
-
--rt-errornames the chemistry rather than guessing:rt1e-4 (default, Q40 -- 10x's figure for the V(D)J RT and X2's own measurement),medium1e-5,high1e-6, or the rate. Never: it is the ONE-MOLECULE floor; 10x's Q60 needs two UMIs to agree and that is arda's job -
--fast, counting mode: the modal exact sequence per group with the per-base best quality of the reads carrying it. No column model, so no error correction -- for when the deliverable is a molecule count. Refused with--contig - Coverage capped at 10,000 reads per barcode into the consensus (10x's rule). Never: the cap is on the reads consensed, never on the reads counted
-
--pre-amp-error auto(2026-08-14), fitted per dataset rather than taken from a named class: X2's estimator run on migec's own consensuses. Molecules at >= 20 reads, the library's modal sequence with one vote per molecule, real variation and divergent templates excluded, and the residual is the floor. Injected 1e-4 -> 1.20e-4 [7.94e-5, 1.75e-4]; injected 1e-5 -> 1.58e-5 [8.14e-6, 2.76e-5]; injected 0 -> the bound 9.56e-6. Never: it REFUSES rather than guessing -- a diverse library (every position polymorphic) and a shallow one (no molecule deep enough) both fall back to the named class and say why, in the report and inassemble.pre_amp_error.tsv. Costs a second assembly pass, which is why it is opt-in. Note: the flag was--rt-errorand that name is kept as an alias, but only an RNA library has a reverse transcription step -- on a DNA library the same floor is library-prep damage plus the first PCR cycle.autofits the floor, not what made it - R1/R2 overlap merge (2026-08-14), as a special case of placement and not a second matcher
in checkout:
assemble --mate2 <R2>on the FASTQ route,--merge-mateson the.migroute where checkout already stored both mates in the record. Mate 2 is reverse-complemented and placed; overlapping mates give one consensus spanning the insert, non-overlapping mates give two contigs. Never: the offset is a property of the MOLECULE, so it is voted once per group over up to eight pairs rather than placed all-against-all -- the first version cost 11x the single-end path (119,820 record-pairs/s against 1,356,819), the vote costs 1,288,686 against 2,115,912. Never: the mates are matched by POSITION and a file that ends early is refused, because pairing off what is left attaches one molecule's mate to another's - Gate: per-base error ≤1e-5 at coverage ≥5 Done: (
tests/synthetic/test_assemble.py, stratified by depth);ê(Q) ≤ 2·10^(−Q/10)for every bucket with n≥1000
M2 — checkout
- MIGEC-dialect pattern grammar, read verbatim from published barcode tables
- Quality-aware log-likelihood acceptance; ambiguous distinguished from unmatched
- UMI extraction from non-contiguous runs, trimming, SAM-style header transfer
- Coverage histogram, composition/entropy/information, collision entropy, count correction
- Bit-parallel matcher (the current scan is O(offsets x pattern) and is not the bottleneck yet)
-
suggest— per-cycle PWM segmented into UMI / constant / payload, paste-ready pattern. Done early (2026-08-13) because X2 needed it; recovered SRR1763769's layout unaided. - Barcode space and error budget built in — nominal vs effective space, occupancy, Poisson
λ,
p_multi, and the Phred + polymerase error prediction against the distance-1 estimate. Logged, warned on, documented, notebooked, tested. - Whitelists with a background hypothesis in the posterior;
Nexpanded, not discarded.--cell-whitelistinrefine. The background prior is measured from barcodes at distance ≥2 from every entry, and it is a prior on this barcode -- the off-list read share divided by the distinct off-list barcodes -- because the whitelist prior is spread over every entry. Snapping scales asn_parent · e/3, so it needs a well-used parent and a poor base - Paired-end input; strand normalisation (tag searched in either mate, pair swapped)
- Multi-core, byte-identical output at any thread count; compression on the workers
- Speed and memory reported per run;
tests/benchmark/regressions - Dual-end barcodes — column 3 of the sheet is MIGEC's slave pattern, on the other mate,
extending the UMI rather than starting a new one. MAGERI's
NNNNNNNNNNNNtgact/agtcaNNNNNNNNNNNNgives a 24 nt UMI; 2000/2000 assigned. Both halves or nothing -
--max-offset, because a positional chemistry cannot be checked out without it. Also fixed: the acceptance bar was charged for the offsets a read could hold rather than the ones actually scanned, so an anchored 5 nt handle (10 bits) was billed 12.6 bits and refused - Positional layouts as the primary mode (2.0.0a2) —
^NNNN/^NNNNXNNN, or half-open slices0:8/0:4,5:10/cell:0:16,16:26.--max-offsetis now automatic and should not be passed: a caret, a slice list, a read structure and a pattern with nothing to score all anchor at 0. Presets for the eight chemistries with names, each carrying a citable source - A run that matches nothing reports the declaration error instead of three statistics computed from reads that never arrived
- The counter can bound itself (2026-08-14):
UmiCounts::enable_spill()range-partitions to disk past a byte budget andfor_each()streams one bucket at a time, reducing on read. A spilled counter gives byte-identical histograms, compositions and distinct counts to a resident one;entries(),find()andmerge()throw rather than answer from the fragment that happens to still be in RAM - Bucketed correction, and the spill switched on in
checkout(2026-08-14). Correction follows the counters into the partition: pass 1 over the buckets as they stand owns the barcode positions the prefix does not touch, pass 2 over a rotated copy owns exactly the ones it hides, and every pair is weighed in one pass and only one. A bucketed run answers with the scalars --root/correctedare indexed againstentries(), which is the array being bounded -- and every one of them matches the resident answer field for field, on a simulated library with injected barcode errors and on a 500,000-read corpus.umi_budget_bytesdefaults to 1 GB for the run; it costs ~2.2x the wall clock when it fires, and nothing when it does not -
.migbucket output (2026-08-14),checkout --mig: the reads are written into the same range partition, on the same key, thatassemblebuilds in its first pass, soassemblereads them and skips that pass. One writer per (sample, bucket), owned by one thread for the whole run, so-tstill changes nothing but the clock; the open-file budget is for the RUN, so a 96-plex sheet gets a couple of buckets each rather than 96 x 256. Opt-in, and FASTQ stays the default: a.migfile is an intermediate nothing else reads. 500 k reads over four samples at-t 4: 1.16 s -> 0.98 s end to end for the identical 124,878 molecules, and the consensus FASTQ is byte-identical after decompression - i7xi5 contingency table (2026-08-14) — the only way index hopping is actually estimable
- Gate: per-sample counts within 2% of MIGEC v1.2.9 on the spike-ins; identical output at 1 and 8 threads ; >1 M reads/s at 16 threads
M3 — refine (the stage works; cell calling and the FDR threshold are open)
Measured before building (scripts/correction_accuracy.py, 2026-08-13): the existing
count-ratio correction works from 3.1 reads/UMI upward (recall ≥0.8, precision ≥0.9) and
collapses below it — 0.02 recall / 0.25 precision at 1.1 reads/UMI. ε follows: 0.98x of injected
at 3.1 reads/UMI, 0.20x at 1.1. Molecules are never lost (≥0.99 kept at every depth), so the
failure is missed correction, not destroyed data. That is the whole shallow regime, so M3's error
model has to use the evidence that survives at one read:
| reads/UMI | recall | precision | ε / true |
|---|---|---|---|
| 1.11 | 0.022 | 0.254 | 0.20 |
| 1.51 | 0.235 | 0.567 | 0.58 |
| 2.32 | 0.587 | 0.846 | 0.86 |
| 3.12 | 0.800 | 0.936 | 0.98 |
| 7.12 | 0.959 | 0.977 | 0.96 |
-
Barcode base quality as evidence (
BarcodeEvidence::position_error).QXwas carried through checkout and unread. A sequencing miscall in the barcode has low Phred at the base it changed; an early-PCR child has a high one in every read. Works at one read. -
Payload agreement as evidence (
BarcodeEvidence::payload). A barcode error child is a read of the parent's molecule, so its payload matches. Worthlog(1/clonality), and the clonality is measured from the data by sampling random barcode pairs — decisive in a diverse repertoire, worth nothing in a clonal library, and the number says which this is. It also lifts the count gates, which is what makes a singleton-vs-singleton merge possible at all, and it refuses merges the count ratio would have made on a disagreeing payload. -
The error likelihood is a rate, not a conditional. The zero-truncated Poisson divided out
(1 − e^−λ)— precisely the term saying whether a child should exist — so for a singleton child it tended to 1 for every λ and the error rate stopped mattering at exactly the coverage where nothing else was available. Untruncated, both sides are expected counts of neighbouring barcodes and the comparison is like for like.Measured after (
scripts/correction_accuracy.py), against the achievable ceiling rather than against all children — a child whose parent barcode was never sequenced has nothing to merge into:reads/UMI reachable recall of those precision molecules kept 1.11 0.204 0.108 0.818 1.000 2.32 0.904 0.816 0.830 0.987 3.12 0.975 0.914 0.926 0.991 7.12 1.000 0.979 0.997 0.999 13.30 1.000 0.983 0.999 1.000 Note: At ~1 read/UMI 80% of barcode errors are unfixable in principle — the parent was never sequenced — and of the rest migec fixes 11% while destroying no real molecule. Precision is the side to err on: a wrong merge deletes a molecule and nothing downstream can tell, a missed correction only inflates the count.
-
migec refineworks: barcode table, correction, read rewrite withOX:Z:preserving the original,<sample>.barcodes.tsv, coverage histogram after correction. Recovered 20,055 molecules from 20,000 simulated with ε at 0.96x of injected. Holds the table, never the reads; three streaming passes -
Quality calibration measured against the pattern's own constant bases, fitted as
ê(q) = ε_qi + a·10^(−q/10)weighted by bases per Q. OnSRR1763769the slope is 1.04 over 46.3 M bases, so the reported Phred's scaling is right. Never: The intercept (3.9e-3) is not a sequencing floor -- the standard is a synthesised oligo, and synthesis runs ~1 defect per 200-500 bases. It is spread evenly over all 23 anchor positions, none polymorphic, and matches the independently measured 0.55% one-base-short rate. Reported as a diagnostic of the primer and left out oferror() -
Sequencing vs quality-independent separation of the template (2026-08-14). The pattern bases can only calibrate the primer, so the standard is the template itself: at 20+ reads a consensus has suppressed sequencing error to nothing, and what it still gets wrong against the library's modal sequence is what was in the molecule before amplification.
--pre-amp-error auto(M1) is that measurement. Still open, and it is data rather than an estimator: splitting the floor into RT and first-cycle PCR needs the same template through both chemistries -
Correction posterior: birthday prior with Rényi-2 collision entropy, phred, and a polymerase mixture component for early-cycle PCR children. The distance-1 background comes from X3's column shuffle, not from
C(n,2)·P_coll·shell -
MIG-size threshold at a target FDR; keep-orphan retention. The residual is measured, not derived: a surviving barcode that still looks like a child of a surviving neighbour, by count or by its reads agreeing on the molecule. Note: Count alone reports zero residual at 1-3 reads per UMI, which is where it is worst. On a 1.23 reads/barcode library: 5.25% of 1-read molecules, threshold ≥2; on 4.62 reads/barcode: 0%, threshold 1. Never: Reported, never applied — every molecule stays in the output
-
Bucketed correction (2026-08-14). refine's table range-partitions itself past a byte budget and correction follows it, in two passes with the key rotated -- a plain partition splits a barcode from its neighbour for the top b/2 positions and would bound the memory while silently not correcting them. Two things it needed that the counter version did not. The table carries the evidence: a
BarcodeEvidenceindexed againstentries()cannot survive a partition, and dropping it leaves the bucketed run on the count ratio alone, which reports nothing at 1-3 reads/UMI. And the two passes scan rather than merge, with one global apply afterwards: a barcode can have a plausible parent on each side of the boundary, and merging inside a pass takes the first rather than the best -- 2 barcodes in 6,591 landed elsewhere than the resident run put them. With both, a partitioned run and a resident one agree on every scalar and on every output file byte for byte (tests/synthetic/test_refine_bucketed.py). It also folded the evidence pass into the table pass, so refine now streams the reads twice rather than three times -
Cell calling (OrdMag + knee) and the QC tables. Molecules per cell, never reads; the knee reported next to the call and a warning when they disagree by more than 3x. On 500 real cells over 20,000 ambient barcodes it calls exactly the 500.
<sample>.cells.tsv -
QC tables:
<sample>.rank.tsv(molecule rank + CDF, log-spaced),<sample>.bins.tsv(per MIG size: barcodes, reads, fraction merged as error, payload entropy), drawn bynotebooks/refine_diagnostics.py -
<sample>.cell_rank.tsv(2.1.0) — Cell Ranger's barcode rank plot, cells sorted by distinct UMIs with the call on the curve. Never reads: one over-amplified molecule would put an empty droplet high up, which is the artefact the plot exists to show -
<sample>.sizes.tsv(2.1.0) — the MIG size spectrum at exact sizes, one row per distinct depth. Power-of-two bins turn the rank/Zipf curve into four steps -
Gate: estimated ε within 20% of injected at 1–3 reads/UMI, not only at 7; ≥95% of no-parent 3–5-read MIGs retained (already ≥99% at every depth measured)
M4 — end to end
-
suggest,subsample, marimo notebooks, full docs - Gate met, measured (
docs/downstream.rst):minimap2 -ax sr -yandbwa mem -CcarryRX/CB/MIinto a valid sorted BAM on 600/600 records;arda ampliconreads the consensus directly and its AIRRsequence_idis the molecule id;salmonandkallistoquantify it plainly. STAR unverified — the brew arm64 build reads 0 reads from any FASTQ -
plot— twenty QC panels drawn with gnuplot from the tables the stages already write, andassets/holds the pipeline figure (graphviz) and the example panels the README shows, regenerated byscripts/example_figures.py - The four familiar figures (2.1.0) — barcode rank on Cell Ranger's axes, the MIG size
spectrum on log1p with molecules and reads, the rank/Zipf curve, unique UMIs per sample
barcode. Consensus quality is a box over an exact
(depth, quality)grid, never a thinned scatter: quality is discrete and capped, so a cloud draws a flat line whatever the bin holds and the thinning removes the only thing it could have added - Publication defaults (2.1.0) — transparent background, one ink colour that reads on light and dark, key inside the plot box rather than in a gutter that widens every figure
- Overrepresented k-mers in
suggest— exact counts in a flat array, measured against the reads' own composition, stitched back into the sequence they came from. Run on a stage's OUTPUT it answers "did the trim remove the primer", which nothing else here could -
checkout.trimming.tsv— the payload length distribution after trimming. A pattern matched one base off still matches; this is where that shows - Never:
sortis not a command. Partitioning happens insideassembleand exposing it would document a third format with no independent meaning (project/design-io-interop.md).
M5 — benchmarks and release
- UMI-tools and fgbio, scored (2026-08-14).
scripts/compare_grouping.pyruns both map-first tools end to end -- barcode into the read name / intoRX, minimap2 onto the simulator's ownclones.fa, thenumi_tools group/fgbio GroupReadsByUmi -s adjacency-- and scores all three partitions against the simulator's truth with the same adjusted Rand index Calib is scored with. The result is what the position is worth: on ONE reference migec wins (ARI 0.9967 against 0.9864 and 0.9817) and wins on the direction that cannot be undone, putting 0.65% of reads into mixed clusters against 3.0% and 3.9% -- 4.6x and 6x fewer molecules destroyed. Nothing rescues that case: molecules colliding on one reference hold the SAME sequence, so neither the position nor payload sub-clustering separates them. On 200 or 20,000 distinct references they win by 0.001 ARI, and that gap is a DEPTH THRESHOLD rather than a limit: collided molecules there carry different sequences andassembleseparates them from the payload with no aligner, but only past the depth 8.68 fixes for itself (log10 C(n, n/2)clears it at n ~ 32). Measured -- 0/12 separated at 9.1 reads on the barcode, 7/14 at 40.9, then 10/10, 13/13, 10/10 at 82, 161 and 283. migec is 8-48x faster including the alignment they cannot skip.docs/grouping.rst,assets/grouping_tools.tsv,assets/collision_split.tsv - MAGERI 1.1.1 head to head (2026-08-14),
scripts/compare_mageri.py, at the consensus and the variant level. Consensus: 4.9-5.1x the wall clock with the alignment folded in, 4.1-8.0x less memory, and 13.5% over truth at one read per MIG against our 0.09% -- within a tenth of a point of MIGEC 1.2.9's 13.6%, which is what a shared count-ratio lineage looks like. Variants, on one reference plus five point variants at a known fraction: at 8 reads per UMI the two are indistinguishable, and at 1.5 reads per UMI they separate 28x with the consensus quality matched to 5e-4 -- 142 calls of which 137 are false against 5 of 5 clean.assets/mageri.tsv,assets/mageri_variants.tsv -
2026-migec-benchmarkrepo andisalgo/umi_data. Every comparator is now run. - Cell Ranger 5.0.0 head to head (2026-08-14),
scripts/compare_cellranger.py, onsc5p_v2_hs_PBMC_1kVDJ-T, both lanes. migec calls 888 cells against 479, sharing 470, and loses 1.9 points of reads-in-cells (84.88% against 86.80%) -- the 418 extra barcodes are nearly empty, so a cell count alone is not an accuracy figure. Barcode validity 88.90% against 90.60%. Never: Cell Ranger is not re-run (Linux x86_64, licence-gated), so the comparator is 10x's own published 5.0.0 output AND acellranger vdj10.1.0 run here (scripts/cellranger_vdj.sbatch, 16 cores on aldan3, 8m44s / 936 MB). The two versions agree at Jaccard 0.9938, which is the control saying the migec gap is not version drift. Cost: 35.0 s / 679 MB against 524.5 s / 936 MB for the stages that answer these axes, ~90 s end to end withassemble --contig+ arda. Never: the two cell sets are different populations, so five counts and never a ratio. It also found thecheckoutUMI-counter bug and proved 5.0.0's publishedMedian TRB UMIs per Cellwrong -- seeCLAUDE.md.assets/cellranger.tsv,docs/single_cell.rst - The per-cell chain axis (2026-08-14),
scripts/compare_cellranger_chains.py. migec assembles per MOLECULE, arda annotates (--cell-from migec), the cell's chain is a vote: TRA 426/426 and TRB 468/469 recall against 5.0.0, and 424/424 + 468/469 against 10.1.0, junction agreement 0.95 / 0.99, in 22 s over 47,584 consensuses with no per-cell assembler. Never: depth does not buy junction coverage on a co-terminal chemistry -- a molecule is a pile at one position, mean consensus 204 nt against the ~508 nt amplicon, 16.5% carry a junction where a per-read-uniform model predicts 0.975.assets/cellranger_chains.tsv -
--contigis 19.7x faster, byte-identical (2026-08-14):place_readsasks union-find before it runs the seed scan, becausejoinalready returns when the roots match. 640.9 s -> 32.6 s on 47,584 molecules, consensus FASTQ andmig.tsvMD5-identical - ctDNA ground truth located, not simulated (2026-08-13):
PRJNA788522(72 runs, cfDNA reference material at 0 / 0.125 / 0.25 / 1% VAF x 5/20/80 ng x 3.3/10/30x, three replicates) andPRJNA507366(28 runs, six polymerases plus 0.031% / 0.0625% VAF). Both carry a real 12 nt inline UMI. Never: Maruzani's runs carry no UMI (aligned BAM submissions,suggestfinds no pattern,CMP_LINKAGE_GROUPempty), so theirs had to be simulated — 9 nt, Phred fixed at 37, assigned to reads sharing start and end, which is the co-terminal assumption X1 falsified. That is a property of the two runs they picked, not of the public record - LoFreq against the certified arms (2026-08-13/14). Full chain on GRCh38 with the panel
inferred from coverage:
assembleconsensus,minimap2 -y, LoFreq, scored per target against the certified VAF. Reliable to 0.25%, and the finding that mattered is that at--min-reads 1the 2-colour dark-G artifact is additive to true positives -- the 0.25% arm read 0.79% (0.25% + a 0.57% artifact floor).--min-reads 3takes specificity from 0% to 100% at no cost to sensitivity.assets/ctdna_minreads.tsv,docs/detection.rst - Mutect2 on the identical consensus BAM (2026-08-14),
scripts/ctdna_callers.sbatch. It settled the question the LoFreq result could not: on the 0%-certified arm at--min-reads 1five of Mutect2's ten positions are LoFreq's and seven of ten are-> G, so the artifact is in the consensus input, not in one caller's threshold. Never: it needs--max-reads-per-alignment-start 0-- the default 50 showed it 78-207 molecules where LoFreq saw 4,282-15,895 on the same BAM, and it then reported ~1 call per sample and never the hotspot. Matched at--min-reads 3, substitutions only, on the true negative: Mutect2 0.67 calls per sample, LoFreq 2.00; Mutect2 pays for it at 0.25%, where it finds the hotspot in 1 replicate of 3 against LoFreq's 3 of 3 - UMIErrorCorrect end to end (2026-08-14),
scripts/ctdna_umierrorcorrect.sbatch-- pipeline against pipeline, since it aligns first and groups on (position, UMI). Matched group size, substitutions only, true negative: 7.67 calls per sample against migec + LoFreq's 2.00, at equal sensitivity except at 1% where it found the hotspot in 2 of 2 completed replicates and migec in 3 of 3. Never: score substitutions against substitutions -- 56% of its PASS calls are deletions and migec emits no indels. Note: it crashed on one of the twelve runs,KeyError: "sequence '10' not present". Note: its 0%-arm spectrum is 9%-> Gagainst migec's 77%: the two pipelines do not fail the same way - Adapter trimming and the MAPQ floor, both measured and both useless (2026-08-14),
scripts/ctdna_trimming.sbatch.-q 20raises the true-negative burden 10.0 -> 25.7 calls per sample at--min-reads 1(the MAPQ 20 call set is a strict superset: 13 shared, 18 added, 16 of the 18-> G); cutadapt raises the off-target molecule share 10.6% -> 13.0% and the burden 10.0 -> 13.0, and costs 2% of molecules. Only--min-reads 3works: off-target 10.6% -> 0.7%, burden 10.0 -> 2.0 - The no-consensus baseline (2026-08-14),
scripts/ctdna_rawreads.sbatch: the same reads, trimming, barcode correction, aligner and caller as the migec row, with a record being a read rather than a molecule. 2.8x the false positives on the certified true negative (5.67 calls per sample against 2.00), the measured frequency 1.27x of certified at 1% and 1.52x at 0.25% where the consensus reads 1.02x and 0.90x, and it detects less from 38x more depth -- 0 of 3 replicates at 0.125% against the consensus's 1 of 3, at 197,772x read coverage against 5,903 molecules. Never: a read count is not a molecule count. Note: the surviving artifact is MORE concentrated after collapsing, 77%-> Gagainst 41%, because the consensus removes the random per-read errors and leaves the common-mode ones - UMI-VarCal: cannot be run on these arms, and the reason is the data shape rather than the
configuration.
Extract.pypairs reads by read id -- each waits in aseendict until the same id appears again -- so on a single-end library both its FASTQs come out empty and the extracted BAM holds 0 of 901,938 reads, after the log reports "Working 100 %". The certified arms are single-end 151 nt. Recorded inSOURCES.mdwith the four unrelated packaging defects found on the way (undocumentedpsutil, anencoding=/max_buffer_size=pair that no msgpack version accepts,extractneeding-f, and a FASTA index cached beside the reference behind anisfilecheck that accepts a 0-byte file) - MIGEC 1.2.9 head to head (2026-08-14),
scripts/compare_migec_v1.py. Same dialect, same sheet, same library, both pipelines end to end,--min-countmatched -- v1 defaults to 5 and we default to 1, and v1 names its output.t5.for exactly that reason, so leaving each at its own default compares defaults. 9.4-11.9x the wall clock against a 3x gate, 3.5-3.7x less memory, and the molecule count is 0.09% over truth against v1's 13.6%: 99.8-99.99% of our consensuses are exactly a template, against 93.9-95.1%.assets/migec_v1.tsv - The published version is single-sourced (2026-08-14).
docs/conf.pywas a fourth hand-written copy of the version and the only one nothing checked, which is how every published page said2.0.0.dev0for three releases while the wheel was right. It readspyproject.tomlwithtomllibnow, so a release bumps THREE places and the docs follow. Never: it must notimport migecto get it --migec._coreis inautodoc_mock_imports, so the package's own version-agreement assertion fires against a Mock and the docs build dies - Gate: grouping ARI ≥0.99 met (0.9967-0.9987); residual error ≤1e-5 on a clonal control; ≥3× MIGEC v1 wall-clock met (9.4-11.9x)
Deliberately not doing
- Alignment and variant calling — MAGERI's job; the pipeline hands off to arda/minimap2/bwa-meme.
- Indels — Illumina rates ~1e-6/base and no dataset to verify against. Substitutions only.
- Duplex consensus (DCS) — v2.0 extracts duplex tags and emits single-strand consensuses. No error-suppression claim in this repo is based on duplex data until DCS exists.
- Full-length receptor assembly, doublet calling and contaminating-chain filtering — arda's
job.
--contigassembles one molecule's fragments into a contig and stops there. - EmptyDrops-style cell rescue — Cell Ranger's job. Ours is OrdMag plus a knee, and the benchmark gate is written against that rather than against a Jaccard we cannot reach.
- An external merge sort — range partitioning plus an in-RAM sort per bucket covers it, and
nbuckets == 1is the in-memory case, so there is one code path rather than two.