Variant Normalization¶
How gbcms prepares variants before counting — validation, left-alignment, and homopolymer decomposition.
Overview¶
Before counting any reads, every variant passes through the preparation pipeline in prepare_variants(). This ensures consistent, biologically correct coordinates regardless of how the input was generated.
flowchart LR
Input(["📄 Input Variant"]):::start --> S1["Step 1: MAF Anchor"]
S1 --> S2["Step 2: REF Validation"]
S2 -->|"REF_MISMATCH"| Fail(["❌ Rejected"]):::fail
S2 -->|"PASS*"| S3["Step 3: Left-Alignment"]
S3 --> S4["Step 4: ref_context Fetch"]
S4 --> S5["Step 5: Homopolymer Detection"]
S5 --> Output(["✅ PreparedVariant"]):::pass
classDef start fill:#9b59b6,color:#fff,stroke:#7d3c98,stroke-width:2px;
classDef step fill:#3498db,color:#fff,stroke:#2471a3,stroke-width:2px;
classDef fail fill:#e74c3c,color:#fff,stroke:#c0392b,stroke-width:2px;
classDef pass fill:#27ae60,color:#fff,stroke:#1e8449,stroke-width:2px;
class S1,S2,S3,S4,S5 step;
Each input variant produces exactly one PreparedVariant, even if validation fails — this guarantees output row count always matches input.
Step 1: MAF Anchor Resolution¶
MAF files represent indels using - dashes. gbcms converts them to VCF-style anchor-based coordinates, which requires fetching the anchor base from the reference FASTA.
| Indel Type | MAF REF | MAF ALT | VCF REF | VCF ALT | Anchor Source |
|---|---|---|---|---|---|
| Insertion | - |
TG |
A |
ATG |
Fetch base at Start_Position |
| Deletion | CG |
- |
ACG |
A |
Fetch base at Start_Position − 1 |
When Is This Step Triggered?
Only for MAF input (is_maf=true) when REF or ALT is -, or when REF and ALT have different lengths (complex indels). VCF input skips this step entirely.
FETCH_FAILED
If the anchor base cannot be fetched (e.g., chromosome not in FASTA), the variant's
gbcms_status is set to FAIL with reason FETCH_FAILED, and it is excluded from counting.
Step 2: REF Validation¶
The REF allele is compared against the reference genome at the stated position. An exact match passes immediately. If the exact match fails, a similarity score is computed — variants with ≥90% base match are corrected to the FASTA REF and proceed to counting.
flowchart TD
Fetch["Fetch ref bases at pos"] --> Exact{"Exact match?"}
Exact -->|"Yes"| Pass(["✅ PASS"]):::pass
Exact -->|"No"| Sim["Compute similarity\n(matching bases / max length)"]
Sim --> Thresh{"≥ 90%?"}
Thresh -->|"Yes"| Corrected(["⚠️ PASS + WARN_REF_CORRECTED\nFASTA REF used downstream"]):::warn
Thresh -->|"No"| Fail(["❌ FAIL + REF_MISMATCH\n→ excluded from counting"]):::fail
classDef pass fill:#27ae60,color:#fff,stroke:#1e8449,stroke-width:2px;
classDef warn fill:#f39c12,color:#fff,stroke:#d68910,stroke-width:2px;
classDef fail fill:#e74c3c,color:#fff,stroke:#c0392b,stroke-width:2px;
When a variant gets a WARN_REF_CORRECTED reason (verdict stays PASS), the MAF's REF allele is replaced with the FASTA sequence for all downstream steps (left-alignment, haplotype construction). The original MAF REF is preserved in original_ref for auditing.
Real-World Example: EGFR Exon 19 Deletion
A 27bp complex EGFR deletion had 26/27 bases matching the FASTA (96%), with only the last base differing due to a MAF annotation artifact. Without tolerance, this variant was silently rejected with zero counts. With tolerant validation, it passes with a WARN_REF_CORRECTED reason and produces valid counts.
Common Causes of REF_MISMATCH
- Wrong reference genome version (GRCh37 vs GRCh38)
- Chromosome naming mismatch (
chr1vs1) - Variant was called against a different reference build
- Upstream normalization changed coordinates incorrectly
- MAF annotation artifact (trailing base error) — now handled by tolerant validation
A REF written a few bases off: REF_AT_OFFSET(k)
REF validation compares only at the stated position. When a REF_MISMATCH
row's REF (3 or more bases, as the row gives it) matches the reference
exactly within 3 bases of its position, gbcms_diagnostic says where:
REF_AT_OFFSET(-1) means one base before. In a repeat every such offset is
listed, nearest first (REF_AT_OFFSET(-1/+1)). The row stays FAIL and is not
counted: gbcms counts the allele as given and does not move it. A shorter
REF is not placed, since a 2-base REF matches within 3 bases by chance
about one time in three. In the MSK sign-out data, 87 of 154 REF_MISMATCH
rows sit 1–3 bases off, most of them legacy indels at Start−1 whose alleles
still carry the VCF anchor base (Start advanced as if it were trimmed).
Step 3: Left-Alignment¶
For indels and complex variants, gbcms applies bcftools-style left-alignment to ensure consistent positioning in repetitive regions.
| Parameter | Value | Description |
|---|---|---|
norm_window |
100bp initial, up to 2500bp | Initial window for leftward search. Doubles on edge-hit (exponential retry). |
max_norm_window |
2500bp | Safety cap for centromeric/telomeric regions |
| Trigger | ref_len ≠ alt_len or both >1bp |
SNPs are never left-aligned |
Every reference window prep fetches (the left-align window, ref_context, the
adaptive repeat scan, an indel's shift region and the event reference) is clamped
to the contig: near a contig end it holds the bases that exist. The FASTA reader
rejects a window that passes the end. So before 6.6.0, each window failed within
its own reach of a contig end: left-alignment within about 100bp, ref_context
within its padding, the shift region within 256bp and the event reference within
60bp. Exact fetches (REF validation, a MAF anchor) are not clamped: a REF running
past the end fails validation.
Dynamic Window Expansion
If a variant shifts all the way to the window edge during left-alignment, it may not have fully converged. The engine automatically doubles the window (100 → 200 → 400 → ... → 2500bp) and retries. This ensures correct normalization even for variants in massive tandem repeats (e.g., centromeric regions) without penalizing the common case.
Every variant that reaches REF validation gets a type label derived from its
final alleles (after anchor resolution, REF correction and alignment), with the
same rule the readers use. A row rejected before that (EMPTY_ALLELE,
NON_SEQUENCE_ALLELE, ALT_EQUALS_REF, START_BEFORE_CONTIG: see QC Flags;
a failed MAF anchor fetch) keeps the reader's label.
| Condition | Assigned Type |
|---|---|
ref_len == 1 && alt_len == 1 |
SNP |
ref_len == 1 && alt_len > 1 and ALT starts with the REF base |
INSERTION |
ref_len > 1 && alt_len == 1 and REF starts with the ALT base |
DELETION |
Otherwise (a delins such as TTAC>A or C>TA, or an MNP) |
COMPLEX |
Counting never reads this label — reads are classified by the alleles — so it is
what gbcms normalize reports, not an input to counting.
Debugging Normalization
Use gbcms normalize to see exactly how each variant was transformed. The output TSV shows original and normalized coordinates side by side, plus granular flags: was_anchor_resolved (MAF dash-allele conversion), was_left_aligned (left-shifting), and was_normalized (either one).
Step 4: ref_context Fetch (Adaptive)¶
For indels and complex variants, a flanking reference sequence is fetched around the (possibly normalized) position. This context serves two purposes:
- Windowed indel detection — Safeguard 3 uses
ref_contextto verify that shifted indels delete/insert the expected reference bases - Phase 3 alignment — the context is used to build REF and ALT haplotypes for dual-haplotype alignment (WFA triage → PairHMM). See Phase 3: Alignment Fallback.
Adaptive Context Padding¶
By default, gbcms dynamically adjusts the context padding based on nearby tandem repeats. This prevents ambiguous SW alignments in repetitive regions where a fixed padding might consist entirely of the repeat motif.
Multi-Anchor Footprint Sweep¶
Historically, scanning only the variant's strict anchor base caused miscalculations for left-aligned INDELs, as the trailing INDEL mutations generated by BWA mapping often fell outside the default 5bp padding window.
The engine now performs a Multi-Anchor Footprint Sweep. It scans across the entire variant footprint (pos through pos + ref_len) looking for tandem repeats, and extracts the maximum necessary padding to clear the repeat entirely.
flowchart LR
SweepFootprint["Sweep variant footprint\n(pos → pos + ref_len)"] --> RepFound{"Tandem repeat\nfound?"}
RepFound -->|"Yes (span = S)"| Formula["padding = max(5, S/2 + 3)\ncapped at 50bp"]
RepFound -->|"No"| Default["padding = 5bp (default)"]
Formula --> Fetch["Fetch ref_context"]
Default --> Fetch
| Parameter | CLI Flag | Default | Description |
|---|---|---|---|
context_padding |
--context-padding |
5 | Minimum flanking bases on each side (range: 1–50) |
adaptive_context |
--adaptive-context |
✅ enabled | Dynamically increase padding across the footprint in repeat regions |
Example: Poly-A Insertion¶
Variant: chr7:100 A→AAAAAAAAAAAAA (12bp poly-A insertion)
Footprint: chr7:100-101
Flanking: ...AAAAAAAAAA[anchor]AAAAAAAAAA...
Sweep Detected: homopolymer max span=20bp → padding = max(5, 20/2+3) = 13bp
Fixed padding=5: AAAAA + REF/ALT + AAAAA (all A's — ambiguous, BWA alignments drop)
Adaptive padding=13: GCTTAAAAA... + REF/ALT + ...AAAAATTGAC (anchored)
Supported Repeat Types¶
| Type | Example | Motif Size | Detection Range |
|---|---|---|---|
| Homopolymer | AAAAAAA |
1bp | ≥2 copies |
| Dinucleotide | CACACACA |
2bp | ≥2 copies |
| Trinucleotide | CAGCAGCAG |
3bp | ≥2 copies |
| Up to hexanucleotide | GGCCCCGGCCCC |
4–6bp | ≥2 copies |
Disabling Adaptive Context
Use --no-adaptive-context to fall back to fixed padding. This may be useful for strict reproducibility against older versions.
SNPs Don't Need Context
SNPs are checked by a simple single-base comparison, so ref_context is not fetched for them. This saves unnecessary FASTA lookups.
Step 5: Homopolymer Decomposition Detection¶
Some variant callers merge nearby events in a homopolymer run into one complex variant with an inflated deletion, e.g. CCCCCC→T where the reads show a smaller change at the run's end. --rescue-homopolymer is deprecated in 6.7.0 and removed in 6.8.0: it is accepted, warns and is ignored, and gbcms builds and counts no corrected allele. Until 6.6.x, with the flag, gbcms also counted a corrected allele for such calls and reports whichever of the two more reads support.
Off by default: the row counts the given allele
gbcms takes the input allele as correct. By default the row reports the given allele's counts. When the reads carry a different allele, gbcms_diagnostic names it: OBSERVED_ALLELE(chrom:pos:REF>ALT:n/0) when no read carries the given allele, COEXISTING_ALLELE(...:n/m) when the given allele is present beside a more frequent one. The flag is deprecated and ignored (6.7.0).
The twin became opt-in because its arbitration is not an exact haplotype match. At the case it was built for (SOX2, below), the reads carry CCCCT, a 1bp deletion plus C→T, not the twin CCCCCT. The twin wins there by tolerance. At other real twin loci it claims most of the called allele's own exact carriers. OBSERVED_ALLELE names CCCCT exactly.
Detection Criteria¶
All four conditions must be met:
flowchart LR
C1{"C1: REF is homopolymer?\n(all same base, ≥3bp)"}
C2{"C2: Net deletion?\n(ref_len > alt_len)"}
C3{"C3: ALT last base ==\nnext ref base after span?"}
C4{"C4: ALT last base ≠\nhomopolymer base?"}
Detected(["🔍 Decomposition detected"]):::detect
Skip(["⏭️ Skip — not a decomposition"]):::skip
C1 -->|Yes| C2
C2 -->|Yes| C3
C3 -->|Yes| C4
C4 -->|Yes| Detected
C1 -->|No| Skip
C2 -->|No| Skip
C3 -->|No| Skip
C4 -->|No| Skip
classDef detect fill:#f39c12,color:#fff,stroke:#d68910,stroke-width:2px;
classDef skip fill:#95a5a6,color:#fff,stroke:#7f8c8d,stroke-width:2px;
Corrected Allele Construction¶
When detected, a corrected ALT allele is built by keeping every base of the run but the last and replacing that last base with the base that follows the run. The corrected allele has the same length as REF:
Example: REF = CCCCCC, ALT = T, next_ref_base = T
Step 1: homopolymer base = C
Step 2: Confirmed: alt_last (T) == next_ref_base (T), T ≠ C
Step 3: corrected_alt = C × (ref_len - 1) + alt_last = CCCCC + T = CCCCCT
Dual-Counting Flow¶
Historical (the flag is ignored in 6.7.0, removed in 6.8.0): with --rescue-homopolymer, the corrected variant is stored in PreparedVariant.decomposed_variant; without it, prep builds no twin. During counting, both the original and corrected alleles are counted independently. The result with the higher alt_count wins:
flowchart TD
Input(["⚡ Decomposition detected"]):::warn
Input -->|"in parallel"| CountOrig
Input -->|"in parallel"| CountDecomp
CountOrig["count (original: CCCCCC→T)"]
CountDecomp["count (corrected: CCCCCC→CCCCCT)"]
CountOrig --> Compare
CountDecomp --> Compare
Compare{"corrected AD > original AD?"}
Compare -->|"Yes"| UseDecomp["Return corrected counts"] --> FlagWarn["reason +=\nWARN_HOMOPOLYMER_DECOMP"]:::warn
Compare -->|"No"| UseOrig["Return original counts"] --> FlagPass["gbcms_status = PASS"]:::pass
classDef warn fill:#f39c12,color:#fff,stroke:#d68910,stroke-width:2px;
classDef pass fill:#27ae60,color:#fff,stroke:#1e8449,stroke-width:2px;
The corrected allele is scored as unique sequence (repeat_span 0). In RNA with a GTF it takes the original's gene strand, so --enforce-strandedness applies to it as to the original. WARN_HOMOPOLYMER_DECOMP is set per sample: a row carries it only when that sample's corrected allele won.
A heuristic arbitration
The flag appears only when the corrected allele got more ALT support than the called one. If the original gets more, it is used as-is with a normal PASS status. The comparison is not an exact haplotype match, though. Reads at real loci carry several forms: the called delins, the corrected allele, a 1bp deletion plus the change, or other alleles of the run. Both classifiers can claim reads of forms neither describes exactly. Inspect the reads at flagged loci; a redesign is tracked in the project plan.
Real-World: SOX2
SOX2, CCCCCC→T (6bp→1bp, net −5bp). The reads carry CCCCT.
- Default: the row reports CCCCCC→T (alt=3), and gbcms_diagnostic names CCCCT.
- With --rescue-homopolymer (before 6.7.0): the corrected CCCCCC→CCCCCT (alt=79) wins, and the row reports gbcms_status = PASS, gbcms_status_reason = WARN_HOMOPOLYMER_DECOMP.
Validation Status Reference¶
Status is carried in two fields: a verdict, gbcms_status (exactly PASS or
FAIL), and a |-separated reason list, gbcms_status_reason (empty for a clean
PASS). Both appear in the MAF (two columns) and the VCF INFO (GS = verdict,
GSR = reasons). | is used (never ;/,, which are VCF-unsafe), so the reason
string is byte-identical in the MAF and the VCF.
| Reason | Verdict | Set when | What to do |
|---|---|---|---|
| (empty) | PASS | The variant validated against the reference | — |
WARN_REF_CORRECTED |
PASS | The REF matches the reference at ≥90% of its bases; the reference bases are used downstream (the original REF is kept in original_ref) |
Check the input's REF; counts are for the corrected REF |
WARN_HOMOPOLYMER_DECOMP |
PASS | Never set in 6.7.0 (--rescue-homopolymer is deprecated and ignored; removed in 6.8.0). Before: with the flag, the variant spans a homopolymer, was dual-counted with a corrected allele, and the corrected allele won |
The row reports that allele's counts, not the given one |
MULTI_ALLELIC |
PASS | Another input row is a different ALT at the same locus; sibling-ALT exclusion is active | None; each row counts its own ALT |
TRACT_CLUSTER |
PASS | The variant shares a repeat-tract scan window with a co-annotated length-changing variant (spans need not touch); sibling exclusion and exclusive ALT assignment are active | None; read the rows together |
REF_MISMATCH |
FAIL | The REF matches the reference at under 90% of its bases | Check the build and coordinates; REF_AT_OFFSET(k) in gbcms_diagnostic says where the REF sits when it is a few bases off |
FETCH_FAILED |
FAIL | The reference region could not be read (contig not in the FASTA, past its end) | Check contig naming and the FASTA |
EMPTY_ALLELE |
FAIL | REF or ALT is empty (a malformed indel; a MAF dash allele is -) |
Fix the input row |
NON_SEQUENCE_ALLELE |
FAIL | An allele is not a base sequence (an IUPAC code, a stray character; - outside MAF input); kept in MAF output, written as <NON_SEQUENCE> in VCF output |
Fix the input row |
ALT_EQUALS_REF |
FAIL | ALT equals REF (any case; - for both in a MAF): no change to count |
Fix the input row |
START_BEFORE_CONTIG |
FAIL | A MAF row at Start_Position 0 that is not a dash insertion (a substitution, a deletion, a delins, or an insertion written with sequence alleles). Position 0 is no base, so nothing can be counted. A dash insertion there is counted (resolved to the base after it); see Input Formats. Kept in MAF output, written as <NON_SEQUENCE> at position 1 in VCF output |
Fix the input row (MAF positions are 1-based) |
ALT_CONTAINS_N |
FAIL | The ALT contains an N base |
Fix the input row |
Reasons stack: a PASS variant can carry WARN_REF_CORRECTED|WARN_HOMOPOLYMER_DECOMP.
Filtering Behavior
The pipeline filters on gbcms_status == "PASS", so every PASS variant — with or
without warning reasons — proceeds to counting. FAIL variants are excluded from
counting but still written to the output with their FAIL verdict and reason
(and zero counts), so the rejection reason is in the output file, not only the log.
Limitations¶
- Left-alignment only — gbcms left-aligns but does not right-align. If your input is right-aligned, the windowed scan (±5bp) may still catch it, but left-alignment is recommended.
- Dynamic normalization window — Starting at 100bp, the window automatically doubles (up to 2500bp) when a variant shifts to the window edge. In practice, this covers even variants in centromeric/telomeric repeats. Variants shifted beyond 2500bp won't be corrected.
- Homopolymer detection is conservative — only triggers for homopolymer REF ≥3bp with a clear D(n)+SNV pattern. Other miscollapsed events are not detected.
Related¶
- gbcms normalize — CLI command for standalone normalization
- Input Formats — VCF/MAF coordinate conventions
- Allele Classification — How reads are classified after normalization
abbreviations