Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## [1.0.2] — 2026-05-25

### Fixed

- **DELLY writer**: `FORMAT` field order corrected from `…FT:RC:RCL:RCR…` to `…FT:RCL:RC:RCR…` to match real DELLY 1.x output; sample column values reordered accordingly so `RC` stays the SV-region count and `RCL`/`RCR` the left/right control regions.
- **Manta writer**: DUP records now emit `<DUP:TANDEM>` as ALT (matching the `##ALT` declaration in the Manta header) instead of the undeclared `<DUP>`.
- **DELLY writer**: BND records now use the standard VCF 4.2 breakend notation (e.g. `]chr3:151368767]C`) instead of the symbolic `<BND>` allele, matching real DELLY output.
- **Sampler**: SV anchor `REF` base is now drawn uniformly from A/C/G/T per record instead of being hardcoded to `N`. This makes BND ALT strings look like `G]chr1:42]` instead of `N]chr1:42]`.

### Added

- README "Scope & limitations" section documenting which INFO/FORMAT fields are declared in the bundled headers but not (yet) populated, split by caller (Manta vs DELLY).

## [1.0.1] — 2026-04-28

### Fixed
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ pip install -e ".[dev,test]"

For ready-to-run command lines (single-sample `gen`, paired somatic `gen-pair`, validation, banks, and dev checks), see [`docs/ready-to-use.md`](docs/ready-to-use.md).

## Scope & limitations

svForge ships the full header of each target caller verbatim, but for now it does not populate every INFO/FORMAT field a real run would emit.
The fields below are declared in the header for parser compatibility but left empty in records — support may land in a later release.

**Manta** — not yet populated: `CIPOS`, `CIEND` (alignment-derived breakpoint CIs), `HOMSEQ` (microhomology sequence; `HOMLEN` is populated), `BND_DEPTH`, `MATE_BND_DEPTH` (per-breakend coverage).

**DELLY** — not yet populated: `RDRATIO` (tumor/normal read-depth ratio), `INSLEN`, `SRMAPQ`, `CONSENSUS`, `CONSBP` (split-read consensus); also, records are currently always tagged `PRECISE` rather than alternating with `IMPRECISE`.

In the meantime, if your downstream pipeline hard-requires any of these fields, you can supply your own `--header-template` that drops the unused declarations, or post-process the VCF to fill them with sentinel values.

## Typical use cases

- Validate downstream filters (for example, `SVFORGE_SOURCE=gnomad` records should disappear after your gnomAD filtering step).
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "svforge"
version = "1.0.1"
version = "1.0.2"
description = "Synthetic VCF generator for structural variants (Manta, DELLY) with controlled variability and realistic artefact injection"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
18 changes: 18 additions & 0 deletions src/svforge/core/sampler.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@

_CHROM_SAMPLE_MARGIN = 10_000
_BLACKLIST_SVTYPES = ("DEL", "DUP", "INV")
_REF_BASES = ("A", "C", "G", "T")


def _random_ref_base(rng: random.Random) -> str:
"""
Return a plausible REF base for an SV anchor (random A/C/G/T)

Real callers always emit a base extracted from the reference fasta. svforge
has no fasta access, so it draws uniformly from the four canonical bases.
This keeps the BND ALT (which embeds REF) looking like e.g. ``G]chr1:42]``
instead of ``N]chr1:42]``
"""
return rng.choice(_REF_BASES)

_T = TypeVar("_T")

Expand Down Expand Up @@ -294,6 +307,7 @@ def _gnomad_entry_to_sv(entry: GnomadEntry, cfg: SamplerConfig, rng: random.Rand
strands="+-",
vaf=vaf,
genotype=genotype,
ref_base=_random_ref_base(rng),
origin=cfg.origin,
source="gnomad",
info_extra={"SVFORGE_SOURCE_ID": entry.source_id},
Expand All @@ -310,6 +324,7 @@ def _gnomad_entry_to_sv(entry: GnomadEntry, cfg: SamplerConfig, rng: random.Rand
strands=_default_strands(entry.svtype),
vaf=vaf,
genotype=genotype,
ref_base=_random_ref_base(rng),
origin=cfg.origin,
source="gnomad",
info_extra={"SVFORGE_SOURCE_ID": entry.source_id},
Expand Down Expand Up @@ -338,6 +353,7 @@ def _blacklist_entry_to_sv(entry: BlacklistEntry, cfg: SamplerConfig, rng: rando
strands=_default_strands(svtype),
vaf=vaf,
genotype=genotype,
ref_base=_random_ref_base(rng),
origin=cfg.origin,
source="blacklist",
info_extra={"SVFORGE_SOURCE_ID": entry.source_id},
Expand Down Expand Up @@ -395,6 +411,7 @@ def _materialize(
homlen=homlen,
vaf=vaf,
genotype=genotype,
ref_base=_random_ref_base(rng),
origin=cfg.origin,
source="bank",
)
Expand All @@ -418,6 +435,7 @@ def _materialize(
homlen=homlen,
vaf=vaf,
genotype=genotype,
ref_base=_random_ref_base(rng),
origin=cfg.origin,
source="bank",
)
Expand Down
41 changes: 34 additions & 7 deletions src/svforge/writers/delly.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,17 @@ def _paired_sample_columns(sv: SV) -> tuple[str, str, str]:
if sv.genotype == "1/1"
else ("0,-5,-50" if sv.genotype == "0/0" else "-10,-1,-10")
)
fmt = "GT:GL:GQ:FT:RC:RCL:RCR:RDCN:DR:DV:RR:RV"
fmt = "GT:GL:GQ:FT:RCL:RC:RCR:RDCN:DR:DV:RR:RV"

tumor_col = (
f"{sv.genotype}:{gl_alt}:{gq}:PASS:40:20:20:2:"
f"{sv.genotype}:{gl_alt}:{gq}:PASS:20:40:20:2:"
f"{ref}:{alt}:{junction_ref}:{junction_alt}"
)

if sv.origin == "germline":
normal_col = tumor_col
else:
normal_col = f"0/0:0,-5,-50:{gq}:PASS:40:20:20:2:30:0:15:0"
normal_col = f"0/0:0,-5,-50:{gq}:PASS:20:40:20:2:30:0:15:0"

return fmt, tumor_col, normal_col

Expand Down Expand Up @@ -117,8 +117,8 @@ def _sample_column(sv: SV) -> tuple[str, str]:
if sv.genotype == "1/1"
else ("0,-5,-50" if sv.genotype == "0/0" else "-10,-1,-10")
)
fmt = "GT:GL:GQ:FT:RC:RCL:RCR:RDCN:DR:DV:RR:RV"
sample = f"{sv.genotype}:{gl}:{gq}:PASS:40:20:20:2:{ref}:{alt}:{junction_ref}:{junction_alt}"
fmt = "GT:GL:GQ:FT:RCL:RC:RCR:RDCN:DR:DV:RR:RV"
sample = f"{sv.genotype}:{gl}:{gq}:PASS:20:40:20:2:{ref}:{alt}:{junction_ref}:{junction_alt}"
return fmt, sample


Expand Down Expand Up @@ -155,8 +155,35 @@ def _base_info(sv: SV) -> list[str]:
return info


def _bnd_alt(ref_base: str, mate_chrom: str, mate_pos: int, strands: str) -> str:
"""
Return the VCF 4.2 breakend ALT string used by real DELLY for BND records

Real DELLY emits the standard breakend notation (e.g. ``]chr3:151368767]C``)
rather than the ``<BND>`` symbolic allele. The ``<BND>`` declaration in the
bundled header is kept verbatim from the reference template for header
fidelity but is never actually consumed by Delly's record format
"""
loc = f"{mate_chrom}:{mate_pos}"
s1, s2 = strands[0], strands[1]
if s1 == "+" and s2 == "+":
return f"{ref_base}[{loc}["
if s1 == "+" and s2 == "-":
return f"{ref_base}]{loc}]"
if s1 == "-" and s2 == "+":
return f"[{loc}[{ref_base}"
return f"]{loc}]{ref_base}"


def _delly_alt(sv: SV) -> str:
if sv.svtype == "BND":
assert sv.mate_chrom is not None and sv.mate_pos is not None
return _bnd_alt(sv.ref_base, sv.mate_chrom, sv.mate_pos, sv.strands)
return f"<{sv.svtype}>"


def _delly_record(sv: SV) -> VCFRecord:
alt = f"<{sv.svtype}>"
alt = _delly_alt(sv)
info = ";".join(_base_info(sv))
fmt, sample = _sample_column(sv)
line = "\t".join(
Expand All @@ -177,7 +204,7 @@ def _delly_record(sv: SV) -> VCFRecord:


def _delly_record_paired(sv: SV) -> VCFRecord:
alt = f"<{sv.svtype}>"
alt = _delly_alt(sv)
info = ";".join(_base_info(sv))
fmt, tumor_col, normal_col = _paired_sample_columns(sv)
line = "\t".join(
Expand Down
16 changes: 14 additions & 2 deletions src/svforge/writers/manta.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,20 @@ def _base_info(sv: SV) -> list[str]:
return info


def _symbolic_alt(svtype: str) -> str:
"""
Return the symbolic ALT used by Manta for ``svtype``

Manta declares ``<DUP:TANDEM>`` (not ``<DUP>``) in its header ALT meta and
every DUP record in the wild carries that exact symbolic value
"""
if svtype == "DUP":
return "<DUP:TANDEM>"
return f"<{svtype}>"


def _symbolic_record(sv: SV) -> VCFRecord:
alt = f"<{sv.svtype}>"
alt = _symbolic_alt(sv.svtype)
info = ";".join(_base_info(sv))
fmt, sample = _sample_column(sv)
line = "\t".join(
Expand All @@ -146,7 +158,7 @@ def _symbolic_record(sv: SV) -> VCFRecord:


def _symbolic_record_paired(sv: SV) -> VCFRecord:
alt = f"<{sv.svtype}>"
alt = _symbolic_alt(sv.svtype)
info = ";".join(_base_info(sv))
fmt, normal_col, tumor_col = _paired_sample_columns(sv)
line = "\t".join(
Expand Down
Loading