Skip to content

genomeGenerate: libsais builder, --genomeType SuperTranscriptome, --genomeTransformOutput SAM - #109

Open
BenjaminDEMAILLE wants to merge 7 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/index-libsais
Open

genomeGenerate: libsais builder, --genomeType SuperTranscriptome, --genomeTransformOutput SAM#109
BenjaminDEMAILLE wants to merge 7 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/index-libsais

Conversation

@BenjaminDEMAILLE

@BenjaminDEMAILLE BenjaminDEMAILLE commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

genomeGenerate: libsais suffix-array builder, selected by --limitGenomeGenerateRAM, plus four genome-index parameters.

What changed

sa_build::build_libsais builds the suffix array with libsais instead of caps-sa, with automatic alphabet-width selection (u8/u16 for few segments, i32 large-alphabet for many-segment genomes).

Selection is driven by --limitGenomeGenerateRAM, not an environment variable. An earlier revision of this PR gated libsais behind RUSTAR_USE_LIBSAIS=1, which reads as debug scaffolding and is not how STAR exposes a build choice. libsais is now used when its estimated peak (17 × N bytes) fits the RAM limit, and caps-sa's external-memory path is used otherwise. That turns --limitGenomeGenerateRAM — previously warned-and-ignored — into a real flag.

--genomeTransformOutput SAM reports alignments in the original genome's coordinates. --genomeTransformType Haploid bakes a VCF's variants into the genome so reads carrying those alleles align without mismatches, but every reported coordinate then refers to a genome nobody else has. Each alignment is mapped back through the conversion blocks written at build time: an indel baked into the sequence reappears as an I/D CIGAR operation at the original position, and junction motifs are reclassified against the original genome rather than the transformed one.

Transcripts are converted immediately after alignment, before anything reads them, so records, junction counts and statistics share one coordinate space. Converting at the writer instead would leave SJ.out.tab and the counters describing a genome the SAM file does not. The SAM header comes from the original genome as well — original coordinates under transformed reference lengths would produce a file no downstream tool could read correctly.

A paired result converts as a unit: if one mate fails to convert the pair is dropped whole rather than reported as a half-mapped read that never existed. Insert size is recomputed from the converted mates.

Combinations whose other outputs would remain in transformed space are refused: --quantMode, --soloType, --outWigType. --genomeTransformOutput SJ and Quant are unimplemented and rejected.

--genomeType SuperTranscriptome condenses the genome to the union of its annotated exons: overlapping exons merge, the merged intervals concatenate, and overlapping transcripts group into superTranscripts, one per condensed chromosome (st0, st1, ...). The index then covers only exonic sequence, and introns cannot be crossed because they are not present. Requires --sjdbGTFfile; Transcriptome is still refused.

The condensed genome comes back with its annotation already addressed to it, so the transcriptome tables, the junction extractor and the suffix array consume it unchanged. Four files are written alongside the index, as STAR writes them: superTranscriptSequences.fasta, transcriptSequences.fasta, superTranscriptSJcollapsed.tsv, and fullGenome/conversionToFullGenome.tsv.

D20, deliberately not reproduced. STAR condenses the sequence before it fills the reverse-complement half of its genome buffer, so every minus-strand exon reads the allocation's spacer bytes and minus-strand superTranscripts come out as uninitialised memory. Here that half is already filled, so a minus-strand exon yields its actual reverse complement. Reproducing STAR would mean writing code whose correct behaviour is to emit garbage and a test asserting it. Recorded in docs-old/dev/divergences.md; the test asserts the hand-derived reverse complement and that no 0x00 byte reaches the output.

One behaviour worth stating because it looks like a gap: a transcript's own intron leaves no junction behind. With nothing else annotated between two exons they end up contiguous in the condensed genome, so there is no gap left to record. A junction survives only when another transcript's exon separates them. Both cases have a test.

Also adds --genomeSuffixLengthMax and --sjdbInsertSave to the parameter surface, accepted and documented inert; off-menu values are rejected rather than silently ignored.

Why

Index construction was the slowest step with no output risk attached: a suffix array is unique for a given text, so a faster builder cannot change results. build_libsais sorts the same per-segment sentinel-transformed text the existing caps-sa sentinel arm uses, so its packed output is byte-for-byte identical to build(), and hence to STAR 2.7.11b.

The RAM gate exists because the two builders trade differently. libsais builds in memory; caps-sa's default path spills to disk. On a 48 MB / 24-chromosome genome:

Builder Construction time Peak RSS SA output
caps-sa 12.8 s ~585 MB 396,000,003 bytes
libsais 4.2 s ~1.49 GB byte-identical

~3.0× faster at ~2.5× the peak RAM. Extrapolated to human (2N ≈ 6.2 B), libsais would need ~50 GB while caps-sa external-memory stays at a few GB — hence the gate rather than a flat default.

Verification

Byte-identity checked at four levels, all green:

  • unit — build_libsais == caps-sa build() on single-chromosome, multi-chromosome-with-N, and repetitive fixtures
  • large-alphabet — the i32 arm forced on small fixtures
  • streaming — build_libsais driven exactly as GenomeIndex::build does, matching the caps-sa streaming byte stream
  • real genome — the 48 MB genomeGenerate run above: identical SA and SAindex

The back-transform is verified end to end: a genome with a 5-base deletion at position 5001, one read at 6000. Without the flag the reported POS is 5996; with it, 6001 — the original coordinate — and the CIGAR is unchanged, since the read spans no variant. Unit tests cover an exon straddling a block boundary coming back out as 10M5D10M, a seamless split collapsing again, a motif read from the original genome, a junction canonical only in the transformed genome turning non-canonical, and an alignment outside the block map being dropped rather than guessed at.

libsais is pinned with default-features = false (no OpenMP); libsais-sys builds its C core via cc. CI green on all 5 platforms including windows-x86_64. On-disk index format unchanged.

SuperTranscriptome is verified end to end: the 20 kb test genome with its two annotated 50 bp exons condenses to a single 100 bp st0; a read straddling the 200 bp intron aligns 50M at position 26, and the conversion map names the two source intervals at 10000 and 10250. Unit tests cover exon merging, the condensed annotation, disjoint transcripts becoming separate superTranscripts, junction collapsing, and D20.

585 lib + 23 integration tests green, clippy -D warnings clean, cargo fmt --check clean, MSRV 1.89.

Note: docs-old/dev/divergences.md is created here with the D20 entry and also on #152 with the solo entries. Whichever merges second merges the two entry lists.

Bookkeeping note: the four flags above are listed in NOT_YET_ACCEPTED on #147. Whichever merges second moves them out of that list in the same commit.

🤖 Generated with Claude Code

@BenjaminDEMAILLE
BenjaminDEMAILLE marked this pull request as ready for review July 23, 2026 18:38
@BenjaminDEMAILLE BenjaminDEMAILLE changed the title feat(index): byte-identical suffix-array construction via libsais feat(index): suffix-array construction via libsais (byte-identical to caps-sa) Jul 23, 2026
BenjaminDEMAILLE and others added 3 commits July 28, 2026 21:24
Add `sa_build::build_libsais`, an alternative suffix-array constructor using
libsais (fast SA-IS) instead of caps-sa. It sorts the same per-segment
sentinel-transformed text the caps-sa sentinel arm uses; because a suffix array
is unique for a given text, the packed output is byte-for-byte identical to
`build()` (and hence to STAR 2.7.11b). Validated by the
`build_libsais_matches_caps_sa_*` tests over single-chr, multi-chr-with-N, and
repetitive fixtures.

- libsais is depended on with `default-features = false` (no OpenMP), so
  libsais-sys builds the C core via `cc` without an OpenMP toolchain (keeps the
  5-platform CI portable).
- Covers genomes whose per-segment sentinel alphabet fits u8/u16; larger segment
  counts fall back to `build()` until the libsais large-alphabet (i32) path is
  wired. Not yet swapped in as the default builder.

Roadmap PR-15 (index-construction speedup). Next: large-alphabet path + byte-
identical validation on the yeast and human genomes, then switch `build()` over
and benchmark. See docs-old/dev/porting-from-star-rs.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend build_libsais with the i32 large-alphabet arm so libsais covers genomes
whose per-segment sentinel alphabet exceeds u16 (many chromosomes + sjdb),
removing the previous caps-sa fallback for alphabet width. The width is
auto-selected (u8/u16/i32); an internal forceable width lets tests exercise the
i32 arm on small fixtures without a >64K-segment genome. Byte-identical to
build() (build_libsais_i32_matches_caps_sa_* tests).

libsais large-alphabet uses an i32 SA buffer, which bounds this arm to texts
shorter than 2^31; huge sequences (e.g. human) still use caps-sa's external-
memory path. Selecting libsais vs caps-sa by available memory, and switching
build()/build_streaming over, remain roadmap follow-ups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `libsais_peak_bytes` and `libsais_fits_in_ram`: the resident-memory
estimate for the in-memory SA-IS builder (~17 bytes per base — the suffix array
and libsais's working array at 8 bytes an entry, plus the text) and the gate
that compares it against --limitGenomeGenerateRAM.

This is the mechanism that turns --limitGenomeGenerateRAM into a real flag
rather than one that is accepted and warned about. Both builders produce
byte-identical output, so the choice is purely about resources: libsais is
roughly 3x faster but must hold everything resident, caps-sa's external-memory
path is slower and bounded.

Deliberately not wired into genomeGenerate yet: build_streaming has to learn to
emit from an in-memory SA before the choice can be acted on, and shipping a log
line that names a builder the code does not actually use would be worse than
shipping nothing. The gate is tested on its own here, including the GRCh38 case
where the 31 GB default correctly selects the external-memory path.

Replaces the RUSTAR_USE_LIBSAIS=1 environment gate from the original branch,
which read as debug scaffolding rather than a STAR-shaped interface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BenjaminDEMAILLE BenjaminDEMAILLE changed the title feat(index): suffix-array construction via libsais (byte-identical to caps-sa) genomeGenerate: libsais suffix-array builder, selected by --limitGenomeGenerateRAM Jul 28, 2026
BenjaminDEMAILLE and others added 2 commits July 29, 2026 01:04
…x, sjdbInsertSave

Four genome-side STAR parameters, each honest about what it does.

`--genomeType` accepts `Full` and refuses `Transcriptome` and
`SuperTranscriptome`. Those need a different index layout entirely; accepting
them would build an ordinary genome under another name.

`--genomeTransformOutput` accepts `None` and refuses `SAM` and `SJ`. Mapping
alignments back to the original coordinate space is not implemented, and a
silent no-op here is worse than a failure: it would report transformed
coordinates as though they were original ones, and nothing downstream could
tell.

`--sjdbInsertSave` accepts `Basic` and `None`. The inserted-junction files are
not retained either way.

`--genomeSuffixLengthMax` is accepted and inert, with the reason recorded on the
flag: the suffix-array builder exposes no comparison bound, and STAR uses this
only to cap work on highly repetitive genomes. The index is identical either
way, so ignoring it changes no output byte — which is the standard this
codebase already applies to the other inert knobs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`--genomeTransformType Haploid` bakes a VCF's variants into the genome sequence,
so reads carrying those alleles align without mismatches. The price is that
every coordinate in the output then refers to a genome nobody else has. This is
the machinery that pays it back, STAR's `Transcript_transformGenome.cpp`.

Three steps, in STAR's order. Remap each exon through the conversion blocks,
splitting wherever it straddles a boundary because the two halves land at
unrelated original coordinates. Merge back the splits that turn out to be
seamless, and fold the shared part of an uneven gap into the neighbouring exon
so what survives as a gap is a real indel. Then reclassify: junction motifs and
annotation flags are read off the *original* genome, because a junction that was
canonical in the transformed genome need not be canonical in the original one,
and STAR reports what the original says.

`blocks_from_tsv` reads `transformGenomeBlocks.tsv` back. The file already
stores the map keyed on transformed coordinates, which is the order the walk
wants and the reverse of what the build side keeps in memory, so parsing is not
symmetric with writing and the test pins the round trip rather than assuming it.
A sentinel entry terminates the map so the forward walk stops on a comparison
instead of a bounds test, as STAR's does.

Tests cover an exon inside one block, an exon straddling a boundary coming back
out as `10M5D10M`, a seamless split collapsing again, soft clips surviving, a
motif read from the original genome, a junction canonical only in the
transformed genome turning non-canonical, a sub-`alignIntronMin` gap staying a
deletion, and an alignment outside the map being dropped rather than guessed at.

Not yet wired to the SAM writer: that needs the original genome loaded at align
time and is the next commit.
The back-transform now runs. Transcripts are converted immediately after
alignment, before anything reads them, so a single coordinate space holds below
that line: records, junction counts and statistics are all in the original
genome. The alternative — converting at the writer — would leave SJ.out.tab and
the counters describing a genome the SAM file does not.

The SAM header comes from the original genome as well, via `output_genome()`.
Original coordinates under transformed reference lengths would produce a file no
downstream tool could read correctly.

`GenomeIndex` gains `transform_out`, loaded only when the flag is set: the
untransformed genome from `OriginalGenome/`, its annotated junctions, and the
parsed block map. A missing `OriginalGenome/` or `transformGenomeBlocks.tsv` is
an error rather than a fallback — falling back would report transformed
coordinates while claiming they are original.

A paired result converts as a unit. If one mate fails to convert the pair is
dropped whole, since reporting the other alone would invent a half-mapped read.
The insert size is recomputed from the converted mates, because the transform
can change the distance between them.

Combinations whose other outputs would stay in transformed space are refused:
`--quantMode`, `--soloType`, `--outWigType`. `--genomeTransformOutput SJ` and
`Quant` remain unimplemented and are still rejected.

Verified end to end: a genome with a 5-base deletion at 5001, one read at 6000.
Without the flag the reported POS is 5996; with it, 6001 — the original
coordinate — and the CIGAR is unchanged, since the read spans no variant.
@BenjaminDEMAILLE BenjaminDEMAILLE changed the title genomeGenerate: libsais suffix-array builder, selected by --limitGenomeGenerateRAM genomeGenerate: libsais builder selected by --limitGenomeGenerateRAM, and --genomeTransformOutput SAM Jul 29, 2026
Condenses the genome to the union of its annotated exons, STAR's
`GTF_superTranscript.cpp`. Overlapping exons merge, the merged intervals
concatenate, overlapping transcripts group into superTranscripts, one per
condensed chromosome. The index then covers only exonic sequence and introns
cannot be crossed, because they are not there.

The condensed genome comes back with its annotation already addressed to it —
`st0`, one-based, plus strand — so the transcriptome tables, the junction
extractor and the suffix array consume it unchanged. Nothing downstream needs to
know the genome was condensed. `Genome::from_chromosomes` is split out of
`from_fasta` for the same reason: these chromosomes are built, not read.

Four files are written alongside the index, as STAR writes them: the
superTranscript and transcript sequences, the collapsed junction table, and
`fullGenome/conversionToFullGenome.tsv`, which is what makes condensed
coordinates translatable back.

D20, deliberately not reproduced. STAR condenses the sequence before it fills
the reverse-complement half of its genome buffer, so every minus-strand exon
reads the allocation's spacer bytes and minus-strand superTranscripts come out
as uninitialised memory. Here that half is already filled, so a minus-strand
exon yields its actual reverse complement. Reproducing STAR would mean writing
code whose correct behaviour is to emit garbage, and a test that asserts it.
The test asserts the hand-derived reverse complement instead, and that no 0x00
byte reaches the output.

One behaviour worth stating because it looks like a missing feature: a
transcript's own intron leaves no junction behind. With nothing else annotated
between two exons, they end up contiguous in the condensed genome, and there is
no gap left to record. A junction survives only when another transcript's exon
separates them. Both cases have a test.

Verified end to end: the 20 kb test genome with its two annotated 50 bp exons
condenses to a single 100 bp `st0`; a read straddling the 200 bp intron aligns
`50M` at position 26, and the conversion map names the two source intervals at
10000 and 10250.
@BenjaminDEMAILLE BenjaminDEMAILLE changed the title genomeGenerate: libsais builder selected by --limitGenomeGenerateRAM, and --genomeTransformOutput SAM genomeGenerate: libsais builder, --genomeType SuperTranscriptome, --genomeTransformOutput SAM Jul 29, 2026
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