Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ Sections commonly used: Features, Bug fixes, Other changes.

- Read names are cut at `--readNameSeparator` (default `/`), as STAR does. A
read named `foo/1` was previously emitted as `foo/1` where STAR emits `foo`.
- `--soloOutRawBarcodes Observed` writes the raw matrix with one column
per *observed* barcode instead of one per whitelist barcode, matching
what CellRanger's `raw_feature_bc_matrix` contains. Counts are
unchanged; on a 200-cell run `barcodes.tsv` goes from 62 MB to 3.4 kB.
**Not a STAR parameter**; default `Whitelist` keeps STARsolo behaviour.

- **STARsolo single-cell quantification (`--soloType`)** — the 10x
Chromium / plate-based count-matrix pipeline, ported from STAR and
Expand Down
24 changes: 24 additions & 0 deletions DIVERGENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,30 @@ On the 10k yeast PE benchmark, 4 reads differ in alignment score (AS) because ST
**Impact.** Past libc++'s load factor the map rehashes, and the order then depends on the bucket count, which depends on how many distinct barcodes were seen; beyond that size the order diverges. The **values never do** — only which line they appear on. Reading the file by barcode rather than by position is unaffected either way.

**Source.** `src/solo/cell_reads.rs`, locked by `rows_are_emitted_in_reverse_first_appearance_order`. STAR: `SoloFeature_statsOutput.cpp`.
### 3.2 `--soloOutRawBarcodes Observed` (opt-in, non-STAR)

**What STAR does.** STARsolo's raw matrix has one column per whitelist
barcode, whether or not any read carried it. For 10x v3 that is 3 686 400
columns and a 62 MB `barcodes.tsv`, nearly all of it zeros.

**What rustar-aligner does.** The same, by default. `--soloOutRawBarcodes
Observed` narrows the raw matrix to the barcodes that actually hold a count,
which is what CellRanger's `raw_feature_bc_matrix` contains. On a 200-cell
fixture that is 200 columns and a 3.4 kB `barcodes.tsv`.

**Why.** Someone comparing our raw matrix against CellRanger's finds no
overlapping keys at all, because the two files mean different things by "raw".
The flag makes the comparison possible without changing what STARsolo users
get.

**Impact.** The counts are identical either way — same entries, same values,
verified on the fixture — only the columns present differ. This is a non-STAR
flag and needs maintainer sign-off; it is off by default so STARsolo parity is
untouched.

**Source.** `src/solo/count.rs` (`observed_barcodes`), `src/params/mod.rs`
(`solo_out_raw_barcodes`). CellRanger: `outs/raw_feature_bc_matrix/` from a
`cellranger count` run, observed directly rather than taken from its source.

---

Expand Down
15 changes: 15 additions & 0 deletions src/params/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1290,6 +1290,21 @@ pub struct Parameters {
#[arg(long = "soloOutGzip", default_value = "no")]
pub solo_out_gzip: String,

/// Which barcodes the **raw** matrix has columns for. **Not a STAR
/// parameter**; a rustar-aligner addition, default `Whitelist`, which is
/// what STARsolo writes.
///
/// `Whitelist` gives one column per whitelist barcode — 3.7 million of them
/// for 10x v3, whether or not a read ever carried them. `Observed` gives
/// one column per barcode that actually holds a count, which is what
/// CellRanger's `raw_feature_bc_matrix` contains, and turns a
/// hundreds-of-megabytes `barcodes.tsv` into a few kilobytes.
///
/// The counts are identical either way; only the columns present differ.
#[arg(long = "soloOutRawBarcodes", default_value = "Whitelist",
value_parser = ["Whitelist", "Observed"])]
pub solo_out_raw_barcodes: String,

/// Velocyto ambiguous-molecule handling (rustar extension beyond STARsolo).
/// `yes` (default) writes the three `spliced`/`unspliced`/`ambiguous` matrices
/// like STARsolo — exon-only molecules with no junction/intron evidence stay in
Expand Down
63 changes: 54 additions & 9 deletions src/solo/count.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,27 @@ fn build_matrix_body(
))
}

/// The whitelist indices that actually appear as a column in the streamed
/// matrix body, ascending.
///
/// Reads the body once rather than tracking the set during counting, so the
/// default path pays nothing for a feature it does not use.
fn observed_barcodes(body: &tempfile::NamedTempFile) -> Result<Vec<u32>, Error> {
let reader =
BufReader::new(std::fs::File::open(body.path()).map_err(|e| Error::io(e, body.path()))?);
let mut seen: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();
for line in reader.lines() {
let line = line.map_err(|e| Error::io(e, body.path()))?;
// "<gene> <cb1based> <count>", the layout `finalize_matrix` also parses.
if let Some(cb1) = line.split(' ').nth(1)
&& let Ok(cb) = cb1.parse::<u32>()
{
seen.insert(cb.saturating_sub(1));
}
}
Ok(seen.into_iter().collect())
}

/// Write a final `matrix.mtx[.gz]` = MatrixMarket header + (optionally
/// cb-remapped/filtered) body. With `remap = None` the body is copied verbatim
/// (raw); with `Some(map)` only columns in the map survive, renumbered to the
Expand Down Expand Up @@ -1392,20 +1413,45 @@ pub fn write_gene_matrix(
&ctx.gene_ann.gene_names,
gzip,
)?;
write_barcodes(
&raw_dir.join(&barcodes_name),
&ctx.whitelist,
sorted.len(),
gzip,
)?;
// `--soloOutRawBarcodes Observed` narrows the raw matrix to the
// barcodes that actually carry a count, which is what CellRanger's
// `raw_feature_bc_matrix` holds. STARsolo's raw matrix has a column per
// whitelist barcode, so the default keeps that.
let observed: Option<Vec<u32>> = if params.solo_out_raw_barcodes == "Observed" {
Some(observed_barcodes(&body)?)
} else {
None
};
let (raw_cols, raw_remap) = match &observed {
Some(cbs) => {
let map: HashMap<u32, u32> = cbs
.iter()
.enumerate()
.map(|(col, &cb)| (cb, col as u32 + 1))
.collect();
(cbs.len(), Some(map))
}
None => (sorted.len(), None),
};
match &observed {
Some(cbs) => {
write_barcodes_subset(&raw_dir.join(&barcodes_name), &ctx.whitelist, cbs, gzip)?;
}
None => write_barcodes(
&raw_dir.join(&barcodes_name),
&ctx.whitelist,
sorted.len(),
gzip,
)?,
}
finalize_matrix(
&body,
&raw_dir.join(&matrix_name),
gzip,
n_genes,
sorted.len(),
raw_cols,
mstats.nnz,
None,
raw_remap.as_ref(),
)?;
log::info!(
"STARsolo: wrote {}/raw matrix ({} genes × {} barcodes, {} entries){}",
Expand Down Expand Up @@ -2539,7 +2585,6 @@ mod tests {
"at most one molecule per corrected UMI, got {counts:?}"
);
}

#[test]
fn multi_gene_umi_cr_drops_a_tie_entirely() {
let mut tied = HashMap::default();
Expand Down
Loading