Skip to content
This repository was archived by the owner on Jun 15, 2026. It is now read-only.

Commit f1d7cce

Browse files
committed
v1.7.0: PLINK export, ADR-0034 magnitude scoring, punch list fixes
Added: - PLINK export (#29): `allelix export plink` converts any supported genotype format to PLINK1 binary (.bed/.bim/.fam) for plink2, ADMIXTURE, PRSice. Single-sample SNP-major encoding. gnomAD ref/alt for allele coding. No-calls and indels skipped. Fixed: - Multi-allelic strand collision in PLINK coord selection: two-pass loop prefers forward allele matches over complement (same class as CADD #45). - Indel fallback: multi-character alleles skipped instead of producing non-standard BIM rows. Documentation: - ADR-0034: Magnitude scoring scale and ceiling (#23). Formalizes 0-10 scale, ceiling of 9, max-across-sources composite rule. Per-source scoring reserved for v2.0.0. - Test protocol updated with PLINK export section.
1 parent aac9f95 commit f1d7cce

13 files changed

Lines changed: 799 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,27 @@
22

33
All notable changes are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
44

5+
## [1.7.0]
6+
7+
### Added
8+
- **PLINK export (#29).** `allelix export plink` converts any supported
9+
genotype format to PLINK1 binary (.bed/.bim/.fam) for downstream
10+
tools (plink2 PCA, ADMIXTURE, PRSice). Single-sample, SNP-major
11+
encoding. Uses gnomAD ref/alt for allele coding when available.
12+
No-call variants skipped.
13+
14+
### Fixed
15+
- **Multi-allelic strand collision in PLINK coord selection.** At sites
16+
where one alt is the complement of another (e.g. ref=G, alts=A,T),
17+
single-pass coord selection picked the complement match over the
18+
forward match — same bug class as CADD #45. Fixed with a two-pass
19+
loop that prefers forward allele matches.
20+
21+
### Documentation
22+
- **ADR-0034: Magnitude scoring scale and ceiling (#23).** Formalizes the
23+
0-10 scale with practical ceiling of 9. Documents max-across-sources
24+
composite rule and reserves per-source scoring for v2.0.0.
25+
526
## [1.6.1]
627

728
### Added
@@ -1560,6 +1581,7 @@ All notable changes are documented here. Format follows [Keep a Changelog](https
15601581
- GitHub Actions CI matrix on Python 3.11 and 3.12.
15611582

15621583

1584+
[1.7.0]: https://github.com/dial481/allelix/compare/v1.6.1...v1.7.0
15631585
[1.6.1]: https://github.com/dial481/allelix/compare/v1.6.0...v1.6.1
15641586
[1.6.0]: https://github.com/dial481/allelix/compare/v1.5.3...v1.6.0
15651587
[1.5.3]: https://github.com/dial481/allelix/compare/v1.5.2...v1.5.3

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ Open-source command-line toolkit for analyzing raw genotype files from consumer
1010
> HTML/JSON/terminal reports, methylation + pharmacogenomics focused
1111
> commands, report diffing, persistent config with commercial-mode
1212
> safety switch. Build auto-detection from position data (ADR-0021).
13-
> No regex on prose anywhere in production. Release notes:
13+
> No regex on prose anywhere in production. **Latest: v1.7.0** — PLINK
14+
> export, magnitude scoring formalization (ADR-0034). Release notes:
1415
> [`CHANGELOG.md`](CHANGELOG.md).
1516
1617
## Quickstart
@@ -47,6 +48,11 @@ allelix pharmacogenomics tests/fixtures/mock_myhappygenes.txt
4748
# Compare two genotype files (coverage, concordance, strand-flip detection)
4849
allelix compare file1.txt file2.txt
4950

51+
# Export to PLINK1 binary format (.bed/.bim/.fam) for plink2, ADMIXTURE, PRSice
52+
# Expect ~60% monomorphic markers (A2=0) — genotyping chips probe many
53+
# intronic/intergenic sites outside gnomAD's exome coverage.
54+
allelix export plink genotype_file.txt -o output_prefix --build grch37
55+
5056
# Output to a self-contained HTML or JSON report
5157
allelix analyze tests/fixtures/mock_myhappygenes.txt --output report.html
5258
allelix analyze tests/fixtures/mock_myhappygenes.txt --output report.json
@@ -70,6 +76,8 @@ Adding a new format means adding one file to `allelix/parsers/` and registering
7076
| Format | Notes |
7177
|---|---|
7278
| VCF | REF/ALT encoding, `0/1` genotype notation, absence-means-reference semantics. Architecturally different from array parsers — 4-6M variants per file, streaming + batch SQL required. |
79+
| Per-source scoring | Magnitude breakdown by database. Users see which source drove the composite score. |
80+
| PLINK import | Read .bed/.bim/.fam as an input format (complement to the v1.7.0 export). |
7381
| Genome Watchtower | Real-time variant monitoring via database delta feeds. Privacy-preserving: server publishes universal feed, matching happens locally against your deviation set. Replaces full re-analysis with millisecond set intersection. |
7482

7583
## Supported Databases

SECURITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Only the latest minor release receives security fixes.
66

77
| Version | Supported |
88
|---------|-----------|
9+
| 1.7.x ||
910
| 1.6.x ||
1011
| < 1.6 ||
1112

allelix/cli.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1402,5 +1402,115 @@ def config_set(data_dir: Path | None, key: str, value: str) -> None:
14021402
console.print(f"[green]Set {key} = {val_lower}[/green]")
14031403

14041404

1405+
@main.group()
1406+
def export() -> None:
1407+
"""Export parsed genotype data to other formats."""
1408+
1409+
1410+
@export.command("plink")
1411+
@_FILE_ARG
1412+
@click.option(
1413+
"--output-prefix",
1414+
"-o",
1415+
type=click.Path(path_type=Path),
1416+
default=None,
1417+
help="Base path for .bed/.bim/.fam (default: input stem).",
1418+
)
1419+
@_FORMAT_OPT
1420+
@_BUILD_OPT
1421+
@_DATA_DIR_OPT
1422+
def export_plink_cmd(
1423+
file_path: Path,
1424+
output_prefix: Path | None,
1425+
fmt: str | None,
1426+
build: str,
1427+
data_dir: Path | None,
1428+
) -> None:
1429+
"""Convert to PLINK1 binary format (.bed/.bim/.fam).
1430+
1431+
Produces a single-sample, SNP-major .bed file suitable for downstream
1432+
tools (plink2 PCA, ADMIXTURE, PRSice). Uses gnomAD ref/alt for allele
1433+
coding when available; falls back to monomorphic (A2=0) for positions
1434+
without gnomAD coverage.
1435+
"""
1436+
from allelix.exporters.plink import _orient_genotype, export_plink
1437+
1438+
parser = _resolve_parser(file_path, fmt)
1439+
prefix = output_prefix if output_prefix else file_path.with_suffix("")
1440+
build_override = _normalize_cli_build(build)
1441+
metadata = parser.get_metadata(file_path)
1442+
effective_build = build_override or metadata.get("build", "GRCh37")
1443+
resolved = resolve_data_dir(data_dir)
1444+
1445+
variants = list(parser.parse(file_path))
1446+
1447+
variant_by_rsid: dict[str, Variant] = {}
1448+
for v in variants:
1449+
if not v.is_no_call:
1450+
variant_by_rsid[v.rsid] = v
1451+
rsids = set(variant_by_rsid)
1452+
1453+
ref_alt_map: dict[str, tuple[str, str]] = {}
1454+
gnomad = None
1455+
try:
1456+
from allelix.annotators.gnomad import GnomadAnnotator
1457+
1458+
gnomad = GnomadAnnotator(resolved)
1459+
if gnomad.is_ready():
1460+
coord_map = gnomad.bulk_resolve_coordinates(rsids)
1461+
for rsid, coords in coord_map.items():
1462+
if len(coords) == 1:
1463+
_, _, ref, alt = coords[0]
1464+
ref_alt_map[rsid] = (ref, alt)
1465+
else:
1466+
v = variant_by_rsid[rsid]
1467+
pair = {v.allele1, v.allele2}
1468+
for _, _, ref, alt in coords:
1469+
if _orient_genotype(
1470+
v.allele1, v.allele2, ref, alt
1471+
) is not None and pair <= {ref, alt}:
1472+
ref_alt_map[rsid] = (ref, alt)
1473+
break
1474+
else:
1475+
for _, _, ref, alt in coords:
1476+
if _orient_genotype(v.allele1, v.allele2, ref, alt) is not None:
1477+
ref_alt_map[rsid] = (ref, alt)
1478+
break
1479+
except Exception:
1480+
console.print(
1481+
"[yellow]gnomAD coordinate resolution failed; using fallback allele coding.[/yellow]"
1482+
)
1483+
finally:
1484+
if gnomad is not None:
1485+
gnomad.close()
1486+
1487+
written, skipped, indel_skip, mono = export_plink(
1488+
iter(variants), prefix, effective_build, ref_alt_map or None
1489+
)
1490+
skip_parts = []
1491+
if skipped:
1492+
skip_parts.append(f"{skipped:,} no-calls")
1493+
if indel_skip:
1494+
skip_parts.append(f"{indel_skip:,} indels")
1495+
skip_msg = f" ({', '.join(skip_parts)} skipped)" if skip_parts else ""
1496+
console.print(f"Wrote {written:,} variants to {prefix}.bed/.bim/.fam{skip_msg}")
1497+
if mono > 0:
1498+
pct = mono / written * 100 if written else 0
1499+
console.print(
1500+
f"[dim]{mono:,} markers ({pct:.0f}%) exported as monomorphic "
1501+
f"(A2=0, ref/alt unknown or ambiguous).[/dim]"
1502+
)
1503+
if not ref_alt_map:
1504+
console.print(
1505+
"[yellow]gnomAD not available — all homozygous markers exported "
1506+
"as monomorphic.[/yellow]"
1507+
)
1508+
console.print("[yellow]Run `allelix db update` first for proper allele coding.[/yellow]")
1509+
console.print(
1510+
"[dim]Single-sample export. Merging with other samples requires "
1511+
"allele harmonization (--merge-mode or set-all-var-ids).[/dim]"
1512+
)
1513+
1514+
14051515
if __name__ == "__main__":
14061516
main()

allelix/exporters/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
# Copyright (C) 2026 dial481
3+
"""Format exporters for parsed genotype data."""

allelix/exporters/plink.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
# Copyright (C) 2026 dial481
3+
"""PLINK1 binary format (.bed/.bim/.fam) exporter."""
4+
5+
from __future__ import annotations
6+
7+
from typing import TYPE_CHECKING
8+
9+
from allelix.utils.allele import complement, is_strand_ambiguous
10+
11+
if TYPE_CHECKING:
12+
from collections.abc import Iterator
13+
from pathlib import Path
14+
15+
from allelix.models import Variant
16+
17+
_BED_MAGIC = bytes([0x6C, 0x1B, 0x01])
18+
19+
_CHROM_CODES = {
20+
"X": "23",
21+
"Y": "24",
22+
"MT": "26",
23+
}
24+
25+
26+
def _orient_genotype(
27+
allele1: str,
28+
allele2: str,
29+
ref: str,
30+
alt: str,
31+
) -> tuple[str, str] | None:
32+
"""Map user alleles to {ref, alt} in a consistent orientation.
33+
34+
Returns None for palindromic sites, indels, or alleles that don't fit.
35+
Both alleles are tested in the same orientation — no mixed-strand.
36+
"""
37+
if len(allele1) != 1 or len(allele2) != 1:
38+
return None
39+
if is_strand_ambiguous(ref, alt):
40+
return None
41+
42+
pair = {allele1, allele2}
43+
if pair <= {ref, alt}:
44+
return (allele1, allele2)
45+
46+
c1, c2 = complement(allele1), complement(allele2)
47+
if {c1, c2} <= {ref, alt}:
48+
return (c1, c2)
49+
50+
return None
51+
52+
53+
def export_plink(
54+
variants: Iterator[Variant],
55+
prefix: Path,
56+
build: str,
57+
ref_alt_map: dict[str, tuple[str, str]] | None = None,
58+
) -> tuple[int, int, int, int]:
59+
"""Write .bed/.bim/.fam from parsed variants.
60+
61+
Args:
62+
variants: Parsed variant iterator (consumed once).
63+
prefix: Base path for output files.
64+
build: Genome build label (informational, not used for liftover).
65+
ref_alt_map: ``{rsid: (ref, alt)}`` from gnomAD coordinate resolution.
66+
When provided, uses ref/alt to assign A1/A2 for proper allele coding.
67+
When None or rsid missing, falls back to ``A2="0"`` for homozygotes.
68+
69+
Returns:
70+
``(variants_written, no_calls_skipped, indels_skipped, monomorphic_count)``
71+
72+
Note:
73+
No-call variants and indels (multi-character alleles) are dropped.
74+
PLINK1 BIM is SNV-only (single-character A1/A2). Indels would
75+
produce non-standard BIM rows that downstream tools may reject.
76+
"""
77+
fam_path = prefix.with_suffix(".fam")
78+
bim_path = prefix.with_suffix(".bim")
79+
bed_path = prefix.with_suffix(".bed")
80+
81+
fam_path.write_text("0\tSAMPLE\t0\t0\t0\t-9\n")
82+
83+
written = 0
84+
skipped = 0
85+
indels = 0
86+
monomorphic = 0
87+
88+
with bim_path.open("w") as bim_f, bed_path.open("wb") as bed_f:
89+
bed_f.write(_BED_MAGIC)
90+
91+
for v in variants:
92+
if v.is_no_call:
93+
skipped += 1
94+
continue
95+
96+
if len(v.allele1) != 1 or len(v.allele2) != 1:
97+
indels += 1
98+
continue
99+
100+
chrom_code = _CHROM_CODES.get(v.chromosome, v.chromosome)
101+
a1: str
102+
a2: str
103+
bed_code: int
104+
105+
if ref_alt_map and v.rsid in ref_alt_map:
106+
ref, alt = ref_alt_map[v.rsid]
107+
resolved = _orient_genotype(v.allele1, v.allele2, ref, alt)
108+
if resolved is not None:
109+
r1, r2 = resolved
110+
a1 = ref
111+
a2 = alt
112+
a2_count = sum(1 for a in (r1, r2) if a == alt)
113+
if a2_count == 0:
114+
bed_code = 0b00
115+
elif a2_count == 1:
116+
bed_code = 0b10
117+
else:
118+
bed_code = 0b11
119+
else:
120+
a1, a2, bed_code, is_mono = _fallback_coding(v)
121+
if is_mono:
122+
monomorphic += 1
123+
else:
124+
a1, a2, bed_code, is_mono = _fallback_coding(v)
125+
if is_mono:
126+
monomorphic += 1
127+
128+
bim_f.write(f"{chrom_code}\t{v.rsid}\t0\t{v.position}\t{a1}\t{a2}\n")
129+
bed_f.write(bytes([bed_code]))
130+
written += 1
131+
132+
return written, skipped, indels, monomorphic
133+
134+
135+
def _fallback_coding(v: Variant) -> tuple[str, str, int, bool]:
136+
"""Fallback allele coding when ref/alt is unknown.
137+
138+
Returns ``(a1, a2, bed_code, is_monomorphic)``.
139+
"""
140+
if v.is_heterozygous:
141+
alleles = sorted([v.allele1, v.allele2])
142+
return alleles[0], alleles[1], 0b10, False
143+
144+
return v.allele1, "0", 0b00, True
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# ADR-0034: Magnitude Scoring Scale and Ceiling
2+
3+
**Status:** Accepted
4+
**Date:** 2026-06-11
5+
6+
## Context
7+
8+
Allelix uses a 0-10 magnitude scale inspired by SNPedia to rank variant
9+
significance for report sorting. Each annotator independently assigns a
10+
score within this range. The composite score shown in reports is the
11+
maximum across all sources.
12+
13+
No source's scoring logic produces 10. Per-source caps (verified
14+
against code): ClinVar caps at 9.0 (Pathogenic), PharmGKB at 9.0
15+
(Level 1A), GWAS at 9.0 (explicit `min(..., 9.0)` cap), SNPedia
16+
passes through wiki-assigned magnitudes (no cap in code; nothing
17+
scores 10 in the wiki data in practice).
18+
19+
## Decision
20+
21+
The 0-10 scale is retained. The practical ceiling of 9 is intentional,
22+
not a bug. No upstream database asserts absolute certainty about any
23+
variant, and the scoring system reflects that. 10 is reserved headroom
24+
that no current evidence tier reaches.
25+
26+
Max-across-sources is the composite rule. This means the least
27+
conservative source's score dominates. This is a known tradeoff accepted
28+
for v1.x. The failure mode (score inflation from one generous source)
29+
is mitigated by source attribution in reports — every score is labeled
30+
with its origin.
31+
32+
### Future direction (v2.0.0)
33+
34+
Per-source scores will be surfaced alongside the composite in all report
35+
outputs. The composite max rule stays, but the individual contributions
36+
become visible so users can judge the evidence basis themselves. This
37+
separates the three dimensions the current composite conflates: clinical
38+
significance, evidence quality, and actionability.
39+
40+
## Consequences
41+
42+
- 10 is valid but unreachable by design. Do not add scoring logic that
43+
produces 10 without a new ADR.
44+
- Report renderers continue to show a single composite score in v1.x.
45+
- JSON schema does not change in v1.x. The per-source breakdown is a
46+
v2.0.0 schema change.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Each ADR captures one decision: the context that forced it, what we chose, and t
3939
- [ADR-0031: Centralized license descriptors on annotator base class](0031-centralized-license-descriptors.md)
4040
- [ADR-0032: CADD v1.7 integration](0032-cadd-integration.md)
4141
- [ADR-0033: Schema version bump policy](0033-schema-version-bump-policy.md) — supersedes ADR-0032's schema-version stance
42+
- [ADR-0034: Magnitude scoring scale and ceiling](0034-magnitude-scoring-scale.md)
4243

4344
## Writing a new ADR
4445

0 commit comments

Comments
 (0)