Skip to content

solo: CellRanger .h5 matrices, written without libhdf5 (answers #177, stacked on #179) - #180

Closed
BenjaminDEMAILLE wants to merge 10 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/solo-cellranger-h5
Closed

solo: CellRanger .h5 matrices, written without libhdf5 (answers #177, stacked on #179)#180
BenjaminDEMAILLE wants to merge 10 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/solo-cellranger-h5

Conversation

@BenjaminDEMAILLE

@BenjaminDEMAILLE BenjaminDEMAILLE commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Stacked on #179#178#176#175#174#173. Merge in that order.

Closes the last piece of #172's output side, and it is the answer to #177: no dependency. You said no to a build dependency for HDF5, and I think that was right, so this writes the file directly instead. If you would rather not have the file at all, this PR is one commit and dropping it costs nothing downstream.

What this adds

raw_feature_bc_matrix.h5 and filtered_feature_bc_matrix.h5, beside the MEX directories, under --soloOutLayout CellRanger. Same counts, CellRanger's HDF5 layout: a /matrix group with the CSC triplet, the barcodes, and a /matrix/features group.

It exists because scanpy.read_10x_h5, Seurat's Read10X_h5 and CellBender's .h5 path read that single file rather than the directory.

How, given no libhdf5

src/io/hdf5.rs, 785 lines, writes the bytes. Version 0 superblock, version 1 object headers, symbol-table groups with a version 1 B-tree and a local heap, contiguous uncompressed datasets — the oldest and most widely-read variant of each structure, deliberately, since the only job here is producing files other people's readers open.

It does not chunk, compress, or write variable-length types, references, or links beyond a group's symbol table. Groups hold at most 8 entries, which is one symbol-table node; more would need node splitting and the 10x layout has at most six children in any group. Exceeding it is an error, not a silently short file.

Verified with libhdf5, not with our own reader

That distinction is the whole risk of this approach: a hand-written file can satisfy a hand-written reader and still be invalid. So the check runs HDF5's own C library. test/h5_conformance.sh on the 20 000-read fixture:

== libhdf5 opens it and walks the whole tree
   OK   h5ls -r
   OK   h5dump -H
   OK   h5stat

== libhdf5 rewrites it from scratch and finds no difference
   OK   h5repack
   OK   h5diff (no difference)

== the readers this file exists for
   scanpy    : (200, 7127) cells x genes, nnz=13959, sum=15439, first barcode AAAGAACGTCGAGCTC-1, first gene YDL246C
   cellbender: (200, 7127) cells x genes, nnz=13959, sum=15439
   OK   scanpy read_10x_h5 + CellBender loader

h5repack reads the file with libhdf5 and writes a fresh one; h5diff then finds no difference between that and ours. That is the strongest statement available short of a formal validator: the C library round-trips our bytes and agrees on every object.

The two Python readers are scanpy's _read_v3_10x_h5 and CellBender's get_matrix_from_cellranger_h5, transcribed into the script so it has no heavy imports. Both were also run against a real cellranger count 10.0.0 .h5 for comparison, and read ours the same way.

And the .h5 equals the .mtx: same 13 959 entries and 15 439 counts, compared entry by entry, for both the raw and filtered files.

What cargo test can and cannot do here

It cannot validate an HDF5 file — that needs libhdf5, which is the thing we are not depending on. So the suite locks what it can:

  • the_image_is_deterministic_and_matches_the_validated_layout — two builds of the same tree are byte-identical, and the image matches a checksum taken from bytes libhdf5 accepted. If a change moves it, test/h5_conformance.sh has to be re-run before the constant is updated, because only libhdf5 can say whether the new bytes are still valid. That is written in the test.
  • superblock_is_well_formed, every_allocation_is_eight_byte_aligned, symbol_table_entries_are_sorted_by_name, too_many_children_for_one_node_is_refused, an_overlong_string_is_an_error_not_a_truncation, integer_data_is_little_endian_in_element_order, strings_are_null_padded_to_the_field_width
  • test_solo_out_layout_cellranger — extended: both files exist, carry the HDF5 signature, and their superblock end-of-file address equals the actual file length

This is the same shape as the differential tests: the real oracle is external and runs off CI.

Compression

Second commit. Datasets are deflated, through the libdeflater already in the tree, so still no new dependency: a version 3 chunked layout message, a version 1 filter pipeline message, and a version 1 chunk B-tree, one chunk per dataset.

size
uncompressed (first commit) 451 KB
deflated (this PR) 88 216 bytes
CellRanger, same matrix 149 472 bytes

We end up smaller than CellRanger because it pads every string field to 256 bytes and we pad to the longest value present. Values are identical either way.

Datasets at or below 1 kB stay contiguous: a chunk B-tree node is about 2 kB allocated at full capacity, so compressing a small dataset costs more than it saves. CellRanger draws the same line, leaving _all_tag_keys uncompressed.

Two latent bugs fell out of this, and they are the argument for the h5repack check. The fill value message declared no fill value where HDF5 writes a zero-length one, and the filter pipeline carried no filter name. h5dump, h5ls, h5stat, h5py, scanpy and CellBender all accepted the result and returned correct data. h5repack rewrote it into a file h5dump then could not read. Both are fixed; the golden test now says explicitly why the conformance script has to be re-run before its constant is updated.

Where it still differs from CellRanger's file

CellRanger chunks at 65 536 elements; we use one chunk per dataset, so a reader decompresses a whole dataset to touch any of it. Every reader of these files loads them whole, and one chunk means one B-tree entry and no node splitting.

There is a warning, not a refusal, if someone pairs this layout with an explicit --soloOutRawBarcodes Whitelist: the writer holds the barcode strings in memory to size a fixed-width dataset, and a 3.7-million-column raw matrix is expensive. The layout's own default is Observed, so this needs two flags to reach.

genome carries the --genomeDir directory name, since we have no equivalent of the name given to cellranger mkref, and software_version says rustar-aligner, not a CellRanger version. Recorded in DIVERGENCE.md §3.5 with the rest.

Gate: 580 lib + 27 integration tests (plus one --ignored test that writes the golden file for the conformance script), cargo clippy --all-targets -- -D warnings, cargo fmt --check, cargo +1.89 check --all-targets, all green.

BenjaminDEMAILLE and others added 10 commits July 31, 2026 10:06
`--soloUMIfiltering MultiGeneUMI_CR` kept every gene tied at the highest
read count. CellRanger's rule is the opposite on exactly that case: the
gene with the *strictly* highest count takes the UMI, and a tie means no
gene counts it.

STAR walks the genes keeping a running maximum and clears its winner
whenever it meets an equal count
(`SoloFeature_collapseUMIall.cpp:212-224`):

    if (ig.second>maxu) { maxu=ig.second; maxg=ig.first; }
    else if (ig.second==maxu) { maxg=-1; };
    ...
    if ( maxg+1==0 ) continue; // not counted for any gene

One read per gene is the ordinary shape of a multi-gene UMI, and it is
always a tie, so the old rule made the flag inert in practice rather
than merely inaccurate. Measured on a 20 000-read 10x fixture (200 cells
from the real v3 whitelist, 400 genes, 720 UMIs deliberately shared
between two genes), against STAR 2.7.11b with the same flags:

                       identical entries    STAR counts   rustar counts
    before             13 749 / 14 806          15 423          16 465
    after              13 902 / 13 967          15 423          15 414

The flag removed nothing at all before; STAR removes 1 030 counts. The
gap goes from +1 042 to -9.

The outcome does not depend on the order the genes are visited — a
strict maximum always ends as the winner, a tie always ends with none —
so iterating a `HashMap` here stays deterministic.

`multi_gene_umi_cr_drops_a_tie_entirely` pins the case the old tests
missed: they only covered 3 reads against 1, where both rules agree.

Not yet implemented, and stated so rather than left to be discovered:
STAR applies a second condition, that the winning gene must also hold
the top count among *uncorrected* UMIs (`umiGeneMapCount0`, same file,
lines 226-232). That needs the pre-correction counts, which this code
does not keep. The 65 entries still differing out of 13 967 are the
place to look for its effect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…w matrix

STARsolo's raw matrix has a column per whitelist barcode. For 10x v3 that
is 3 686 400 columns and a 62 MB `barcodes.tsv`, nearly all zeros.
CellRanger's `raw_feature_bc_matrix` has a column per *observed* barcode.

The two files therefore share no keys, which is not a rounding difference
in a comparison, it is zero overlap: comparing our raw output against a
real `cellranger count` run gave 0 identical entries out of 27 396 until
the columns were reconciled.

`--soloOutRawBarcodes Observed` narrows the raw matrix to the barcodes
that carry a count. Default `Whitelist` keeps what STARsolo writes, so
nothing changes for anyone not asking.

Measured on the 20 000-read fixture:

    Whitelist  3 686 400 barcodes   barcodes.tsv 62 668 800 bytes
    Observed         200 barcodes   barcodes.tsv      3 400 bytes

with identical counts on both sides: 13 937 entries, 15 414 counts.

`finalize_matrix` already took a column remap for the filtered matrix, so
this reuses it rather than adding a second path. The observed set is read
back from the streamed body, which costs one pass and only when the flag
is on.

This is a **non-STAR flag** and needs sign-off; recorded in
`DIVERGENCE.md` §3.2 rather than presented as parity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
STAR corrects UMIs within each gene *before* deciding which gene owns a
UMI, and applies two conditions, not one
(`SoloFeature_collapseUMIall.cpp:134-148` and `:203-235`):

1. one gene must hold a strictly higher read count than every other, on
   the **corrected** UMI map — that is scverse#173, already landed;
2. and that winner must not be beaten in the **uncorrected** map at the
   same key.

The second condition exists because correction moves reads between UMIs:
a gene can win only because correction folded a neighbouring UMI onto it,
and STAR rejects that win rather than counting it.

Reproducing it needs the order STAR uses. The generic path here filters
multi-gene UMIs first and corrects afterwards, which cannot express either
condition: by the time correction happens the ownership decision is
already made. `MultiGeneUMI_CR` therefore takes its own path, which is
also what STAR does — the flag is only valid with `--soloUMIdedup 1MM_CR`,
so there is no combination this bypasses.

`cellranger_1mm_map` exposes the correction mapping that
`cellranger_1mm` already computed and threw away.

Measured against **CellRanger 10.0.0** on the 20 000-read fixture from
scverse#172, with scverse#165 and scverse#173 also applied:

                              identical entries   CellRanger   rustar
    scverse#165 + scverse#173               13 651 / 13 709        15 111     15 091
    plus this change          13 676 / 13 709        15 111     15 116

Entries CellRanger has and we do not go from 29 to 7, and the count gap
from -20 to +5, which is 0.03%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Aligning 10x data and comparing the count matrix against CellRanger gave
a successful run and different numbers, with nothing in the output
pointing at the five flags that explain the difference. Measured against
CellRanger 10.0.0 on a 20 000-read fixture, those flags are the whole
gap: 8.9% away without them, 0.03% with them.

When the geometry is unambiguously 10x — `CB_UMI_Simple`, a whitelist, a
16-base cell barcode, a 10- or 12-base UMI — the five now default to
their CellRanger values:

    --clipAdapterType      CellRanger4
    --outFilterScoreMin    30
    --soloCBmatchWLtype    1MM_multi_Nbase_pseudocounts
    --soloUMIfiltering     MultiGeneUMI_CR
    --soloUMIdedup         1MM_CR

A flag given on the command line always wins, including when the value
asked for is STARsolo's own default: `value_source` distinguishes an
explicit flag from a default, so the divergence is escapable by naming
what you want. Every substitution is logged at INFO with the geometry
that triggered it.

**This changes default output behaviour on 10x runs and diverges from
STARsolo**, which is why it is confined to a geometry nothing else in
common use shares, why it is announced on every run it touches, and why
it is in `DIVERGENCE.md` §1.3 as the largest entry in that file. It needs
sign-off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-derived both sides from one clean state: the five flags move the matrix
from 8.96% above CellRanger to 2.17% above it, not to 0.03%. The earlier
figure compared a rustar run against a CellRanger run built from a different
state of the fixture. STAR 2.7.11b with the same flags is at +0.09%, so the
remaining gap is open rather than closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writes the same numbers where `cellranger count` writes them:
outs/{raw,filtered}_feature_bc_matrix/, gzipped, a -1 GEM-well suffix on
every barcode, one raw column per observed barcode, and no per-feature
subdirectory when a single feature is requested.

The layout implies --soloOutGzip yes, --soloOutRawBarcodes Observed and an
outs/ output directory; each is still overridable on the command line. On
10x geometry it is the default, alongside the five flags from the previous
commit, so it changes where output files are written on those runs.

Counts are untouched: 13 959 entries and 15 439 counts in both layouts on
the 20 000-read fixture, compared entry by entry. Against a real
cellranger count 10.0.0 run on the same fixture the raw barcode sets match
exactly, 200 of 200.

Recorded in DIVERGENCE.md 3.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writes CellRanger 10.0.0's 20 metrics, in its order and value formats
(thousands separators, quoted when that adds a comma; percentages to one
decimal). STARsolo's Summary.csv is unchanged and still written alongside.

Twelve of the 20 match a real cellranger count 10.0.0 run exactly on the
20 000-read fixture. The other eight rest on an interpretation of a
denominator CellRanger does not document, and each interpretation is stated
in DIVERGENCE.md 3.4 rather than left implicit.

Three metrics needed new counters: Q30Stats tallies Phred >= 30 bases over
the barcode, the UMI and the cDNA read, populated only when the layout asks
for the metrics. The exonic/intronic split needs the gene-body overlap
query, so a Gene-only run under this layout now performs it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds raw_feature_bc_matrix.h5 and filtered_feature_bc_matrix.h5 under
--soloOutLayout CellRanger, holding the same counts as the MEX directories
in CellRanger's HDF5 layout, so scanpy.read_10x_h5, Seurat's Read10X_h5 and
CellBender's .h5 path read them.

No new dependency. src/io/hdf5.rs writes the file directly: version 0
superblock, version 1 object headers, symbol-table groups with a version 1
B-tree and a local heap, contiguous uncompressed datasets. That is the
subset CellRanger's matrix file uses, and it avoids both a system libhdf5
(which would break cargo install) and cmake (which a vendored build needs).

Verified with HDF5's own C library rather than our own reader:
test/h5_conformance.sh runs h5ls -r, h5dump -H and h5stat, then an h5repack
round-trip through libhdf5 followed by h5diff, which reports no difference.
scanpy's and CellBender's loaders both return the expected matrix, and the
.h5 matches the .mtx entry by entry: 13 959 entries, 15 439 counts.

The unit tests lock determinism and the byte layout with a checksum, since
cargo test cannot validate an HDF5 file; the conformance script must be
re-run off CI whenever the writer changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds chunked storage with the deflate filter to the in-tree HDF5 writer:
a version 3 chunked layout message, a version 1 filter pipeline message, and
a version 1 chunk B-tree, with one chunk covering each dataset. Compression
goes through the libdeflater already in the tree, in the zlib framing HDF5's
deflate filter expects.

The .h5 goes from 451 KB to 88 KB on the 20 000-read fixture, against
CellRanger's 149 KB for the same matrix.

Datasets at or below 1 kB stay contiguous: a chunk B-tree node is about 2 kB
allocated at full capacity, so compressing them would cost more than it
saves. CellRanger draws the same line, leaving _all_tag_keys uncompressed.

Two fixes fell out of validating this against libhdf5, both silent before:
the fill value message declared no fill value where HDF5 writes a
zero-length one, and the filter pipeline carried no filter name. Every
reader accepted the result and h5repack rewrote it into a file h5dump could
not read. The conformance script now catches that, and the golden test says
why it must be re-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor Author

Closing per the discussion in #177: an in-tree HDF5 writer adds maintenance surface to rustar for a need that is convenience rather than necessity — CellBender, scanpy and Seurat all read the MEX directory #178 produces. The offer of a standalone pure-Rust crate, hyalite-shaped and transferable to scverse, is in #177 if a real request for the format turns up. The branch keeps the work and the libhdf5 conformance script.

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