genomeGenerate: libsais builder, --genomeType SuperTranscriptome, --genomeTransformOutput SAM - #109
Open
BenjaminDEMAILLE wants to merge 7 commits into
Open
genomeGenerate: libsais builder, --genomeType SuperTranscriptome, --genomeTransformOutput SAM#109BenjaminDEMAILLE wants to merge 7 commits into
BenjaminDEMAILLE wants to merge 7 commits into
Conversation
BenjaminDEMAILLE
marked this pull request as ready for review
July 23, 2026 18:38
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
force-pushed
the
bd/index-libsais
branch
from
July 28, 2026 20:35
f9191a9 to
3f0d3a6
Compare
This was referenced Jul 28, 2026
…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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
genomeGenerate: libsais suffix-array builder, selected by--limitGenomeGenerateRAM, plus four genome-index parameters.What changed
sa_build::build_libsaisbuilds the suffix array withlibsaisinstead ofcaps-sa, with automatic alphabet-width selection (u8/u16for few segments,i32large-alphabet for many-segment genomes).Selection is driven by
--limitGenomeGenerateRAM, not an environment variable. An earlier revision of this PR gated libsais behindRUSTAR_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 × Nbytes) 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 SAMreports alignments in the original genome's coordinates.--genomeTransformType Haploidbakes 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 anI/DCIGAR 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 SJandQuantare unimplemented and rejected.--genomeType SuperTranscriptomecondenses 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;Transcriptomeis 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, andfullGenome/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 no0x00byte 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
--genomeSuffixLengthMaxand--sjdbInsertSaveto 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_libsaissorts the same per-segment sentinel-transformed text the existing caps-sa sentinel arm uses, so its packed output is byte-for-byte identical tobuild(), 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:
~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:
build_libsais== caps-sabuild()on single-chromosome, multi-chromosome-with-N, and repetitive fixturesi32arm forced on small fixturesbuild_libsaisdriven exactly asGenomeIndex::builddoes, matching the caps-sa streaming byte streamgenomeGeneraterun above: identicalSAandSAindexThe 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.libsaisis pinned withdefault-features = false(no OpenMP);libsais-sysbuilds its C core viacc. CI green on all 5 platforms includingwindows-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 aligns50Mat 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 warningsclean,cargo fmt --checkclean, MSRV 1.89.Note:
docs-old/dev/divergences.mdis 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_ACCEPTEDon #147. Whichever merges second moves them out of that list in the same commit.🤖 Generated with Claude Code