Skip to content

ACMGClassifier

vartriage.ACMGClassifier

Assign ACMG/AMP evidence tags and final classification.

The classifier evaluates each ScoredVariant against 13 evidence criteria and combines them through the ClinGen SVI point system.

Pathogenic: - PVS1: null variant (nonsense/frameshift), or splice-site with SpliceAI

0.8. Strength is Very Strong when loss of function is an established mechanism (LoF gene list or pLI > 0.9) and Strong otherwise; a Very Strong result is downgraded to Strong when the variant escapes nonsense-mediated decay (last exon, within 50 nt of the final junction, or single-exon gene) given a transcript-structure lookup. - PS1: same amino acid change as an established pathogenic variant. - PM1: missense in a functionally constrained region (gnomAD mis_z). - PM2: rare in, or absent from, consulted population data. - PM4: protein-length-changing (in-frame indel, stop-loss). - PM5: novel missense at a residue with a known pathogenic change. - PP3: computational evidence (REVEL/SpliceAI), supporting to strong. - PP5: ClinVar Pathogenic. Supporting by default; Strong (PP5_Strong) for an expert-panel review status; does not fire for a no-criteria or conflicting status.

Benign: - BA1: any population AF above the benign ceiling (standalone). - BS1: any population AF above the strong-benign threshold. - BS2: observed homozygous in gnomAD for a dominant-disorder gene. - BP4: low computational pathogenicity score, supporting to moderate. - BP7: synonymous with no predicted splice impact.

PM2, BA1, and BS1 use fixed frequency thresholds by default; when use_disease_thresholds is set and the gene's inheritance mode is known, the thresholds are selected per variant (stricter for dominant disorders).

When a required data source is unavailable for a given criterion, that tag is omitted and the source name is recorded in the output.

Source code in vartriage/classification/acmg.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
class ACMGClassifier:
    """Assign ACMG/AMP evidence tags and final classification.

    The classifier evaluates each ScoredVariant against 13 evidence criteria
    and combines them through the ClinGen SVI point system.

    Pathogenic:
    - PVS1: null variant (nonsense/frameshift), or splice-site with SpliceAI
      > 0.8. Strength is Very Strong when loss of function is an established
      mechanism (LoF gene list or pLI > 0.9) and Strong otherwise; a Very
      Strong result is downgraded to Strong when the variant escapes
      nonsense-mediated decay (last exon, within 50 nt of the final junction,
      or single-exon gene) given a transcript-structure lookup.
    - PS1: same amino acid change as an established pathogenic variant.
    - PM1: missense in a functionally constrained region (gnomAD mis_z).
    - PM2: rare in, or absent from, consulted population data.
    - PM4: protein-length-changing (in-frame indel, stop-loss).
    - PM5: novel missense at a residue with a known pathogenic change.
    - PP3: computational evidence (REVEL/SpliceAI), supporting to strong.
    - PP5: ClinVar Pathogenic. Supporting by default; Strong (PP5_Strong)
      for an expert-panel review status; does not fire for a no-criteria or
      conflicting status.

    Benign:
    - BA1: any population AF above the benign ceiling (standalone).
    - BS1: any population AF above the strong-benign threshold.
    - BS2: observed homozygous in gnomAD for a dominant-disorder gene.
    - BP4: low computational pathogenicity score, supporting to moderate.
    - BP7: synonymous with no predicted splice impact.

    PM2, BA1, and BS1 use fixed frequency thresholds by default; when
    ``use_disease_thresholds`` is set and the gene's inheritance mode is
    known, the thresholds are selected per variant (stricter for dominant
    disorders).

    When a required data source is unavailable for a given criterion, that
    tag is omitted and the source name is recorded in the output.
    """

    def __init__(
        self,
        protein_index: ClinVarProteinIndex | None = None,
        lof_gene_list: frozenset[str] | None = None,
        nmd_lookup: NMDLookup | None = None,
        use_disease_thresholds: bool = False,
    ) -> None:
        """Initialize the classifier with optional protein-level ClinVar index.

        Parameters
        ----------
        protein_index : Optional[ClinVarProteinIndex]
            Pre-loaded index of ClinVar pathogenic missense variants for
            PS1/PM5 evaluation. When None, PS1 and PM5 are omitted with
            the source recorded as missing.
        lof_gene_list : Optional[frozenset[str]]
            Explicit set of gene symbols where LoF is the established
            disease mechanism. When provided, PVS1 fires at Very Strong
            only for genes on this list. Genes not on the list get PVS1
            at Strong (downgraded). When None, pLI-based gating is used.
            Gene names are matched case-sensitively against
            ``variant.annotated.gene_name`` (HGNC symbols, e.g., "BRCA1").
        nmd_lookup : Optional[NMDLookup]
            Transcript CDS index exposing ``escape_zone(gene, chrom, pos)``.
            When provided, PVS1 is downgraded from Very Strong to Strong for
            null variants that escape nonsense-mediated decay (last exon,
            within 50 nt of the final junction, or a single-exon gene). When
            None, ``transcript_structure`` is recorded as missing and PVS1
            keeps its current strength.
        use_disease_thresholds : bool
            When True, BA1/BS1/PM2 frequency gates are selected per variant
            from the gene's inheritance mode (via ``gene_context``). When
            False (default), fixed thresholds are used for backward
            compatibility.
        """
        self._protein_index = protein_index
        self._lof_gene_list = lof_gene_list
        self._nmd_lookup = nmd_lookup
        self._use_disease_thresholds = use_disease_thresholds

    def classify(
        self, variants: Iterator[ScoredVariant]
    ) -> Iterator[ClassifiedVariant]:
        """Assign evidence tags and classify each scored variant.

        Evaluates ACMG/AMP 2015 evidence criteria for each variant, then
        applies combining rules to determine the final classification
        (Pathogenic, Likely_Pathogenic, or VUS).

        Parameters
        ----------
        variants : Iterator[ScoredVariant]
            Stream of scored variants to classify.

        Yields
        ------
        ClassifiedVariant
            Each variant with evidence tags assigned, classification
            determined by ACMG/AMP 2015 combining rules, and missing
            data sources recorded.
        """
        for variant in variants:
            tags, missing_sources = self._assign_tags(variant)
            evidence = frozenset(tags)
            classification = combine_evidence(evidence)
            yield ClassifiedVariant(
                scored=variant,
                evidence_tags=evidence,
                classification=classification,
                missing_data_sources=frozenset(missing_sources),
                has_conflicting_evidence=has_conflicting_evidence(evidence),
            )

    def _assign_tags(self, variant: ScoredVariant) -> tuple[set[EvidenceTag], set[str]]:
        """Evaluate all evidence criteria for a single variant.

        Parameters
        ----------
        variant : ScoredVariant
            The variant to evaluate.

        Returns
        -------
        tuple[set[EvidenceTag], set[str]]
            A tuple of (assigned tags, missing data source names).
        """
        tags: set[EvidenceTag] = set()
        missing_sources: set[str] = set()

        self._evaluate_pvs1(variant, tags, missing_sources)
        self._evaluate_ps1(variant, tags, missing_sources)
        self._evaluate_pm1(variant, tags, missing_sources)
        self._evaluate_pm2(variant, tags, missing_sources)
        self._evaluate_pm4(variant, tags, missing_sources)
        self._evaluate_pm5(variant, tags, missing_sources)
        self._evaluate_pp3(variant, tags, missing_sources)
        self._evaluate_pp5(variant, tags, missing_sources)

        # Benign criteria
        self._evaluate_ba1(variant, tags, missing_sources)
        self._evaluate_bs1(variant, tags, missing_sources)
        self._evaluate_bs2(variant, tags, missing_sources)
        self._evaluate_bp4(variant, tags, missing_sources)
        self._evaluate_bp7(variant, tags, missing_sources)

        return tags, missing_sources

    def _resolve_thresholds(self, variant: ScoredVariant) -> DiseaseThresholds:
        """Select the BA1/BS1/PM2 frequency thresholds for a variant.

        When disease-aware thresholds are disabled, or the variant has no
        gene context with an inheritance mode, the fixed default thresholds
        are returned (backward compatible). Otherwise the gene's inheritance
        mode selects dominant (stricter) or recessive thresholds. The most
        clinically significant mode across the gene's disease associations
        wins: a dominant association tightens the gates even if a recessive
        one is also recorded.
        """
        if not self._use_disease_thresholds:
            return DEFAULT_THRESHOLDS

        gene_context = variant.annotated.gene_context
        if gene_context is None or not gene_context.disease_associations:
            return DEFAULT_THRESHOLDS

        modes = {
            a.inheritance_mode.upper()
            for a in gene_context.disease_associations
            if a.inheritance_mode
        }
        if not modes:
            return DEFAULT_THRESHOLDS
        if "AD" in modes:
            return DOMINANT_THRESHOLDS
        if modes & {"AR", "XL", "XLR", "XLD", "MT"}:
            return RECESSIVE_THRESHOLDS
        return DEFAULT_THRESHOLDS

    def _evaluate_pvs1(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        missing_sources: set[str],
    ) -> None:
        """Assign PVS1 for null variants with LoF constraint gating.

        For NONSENSE or FRAMESHIFT: fires PVS1 at Very Strong when LoF
        is the established mechanism (pLI > 0.9 or gene on lof_gene_list),
        at Strong otherwise, including when the mechanism is unknown for
        want of constraint data.

        For SPLICE_SITE: fires PVS1 at Very Strong only when SpliceAI > 0.8.
        """
        consequence = variant.annotated.consequence

        if consequence in _PVS1_CONSEQUENCES:
            tag = self._resolve_pvs1_strength(variant)
            tags.add(tag)
            # When PVS1 stands at Very Strong but no transcript structure was
            # available to check NMD escape, record the source so the output
            # reflects that the downgrade check could not run.
            if tag is EvidenceTag.PVS1 and self._nmd_lookup is None:
                missing_sources.add("transcript_structure")
            return

        if consequence == FunctionalConsequence.SPLICE_SITE:
            spliceai = variant.spliceai_score
            if spliceai is None:
                missing_sources.add("SpliceAI")
                return
            if spliceai > _PVS1_SPLICEAI_THRESHOLD:
                tags.add(EvidenceTag.PVS1)

    def _resolve_pvs1_strength(self, variant: ScoredVariant) -> EvidenceTag:
        """Determine PVS1 strength based on gene LoF mechanism evidence.

        PVS1 at Very Strong is warranted only when loss of function is an
        established disease mechanism for the gene. Priority:
        1. Explicit lof_gene_list (gene on list -> Very Strong, off -> Strong)
        2. gnomAD pLI constraint (pLI > 0.9 -> Very Strong, otherwise Strong)
        3. No mechanism evidence -> Strong (the mechanism is unknown, so the
           criterion does not reach Very Strong on absence of data).

        A Very Strong result is then downgraded to Strong when the variant
        escapes nonsense-mediated decay (NMD), because such a truncating
        variant may still produce a partially functional protein. The
        downgrade only ever lowers strength; it never raises it.
        """
        gene_name = variant.annotated.gene_name

        # Explicit gene list takes priority when provided
        if self._lof_gene_list is not None and gene_name is not None:
            if gene_name in self._lof_gene_list:
                return self._apply_nmd_downgrade(variant, EvidenceTag.PVS1)
            return EvidenceTag.PVS1_STRONG

        # Fall back to gnomAD pLI constraint
        gene_context = variant.annotated.gene_context
        if gene_context is None or gene_context.constraint is None:
            return EvidenceTag.PVS1_STRONG

        if gene_context.constraint.is_lof_intolerant:
            return self._apply_nmd_downgrade(variant, EvidenceTag.PVS1)

        return EvidenceTag.PVS1_STRONG

    def _apply_nmd_downgrade(
        self, variant: ScoredVariant, resolved: EvidenceTag
    ) -> EvidenceTag:
        """Downgrade a Very Strong PVS1 to Strong when the variant escapes NMD.

        Applies only to ``EvidenceTag.PVS1`` (Very Strong). A variant in the
        last exon, within 50 nt of the final exon-exon junction, or in a
        single-exon gene escapes nonsense-mediated decay and is downgraded to
        ``PVS1_STRONG``. When no NMD lookup is configured the strength is
        unchanged; the missing source is recorded by the caller via
        ``_evaluate_pvs1``.
        """
        if resolved is not EvidenceTag.PVS1 or self._nmd_lookup is None:
            return resolved

        gene_name = variant.annotated.gene_name
        if gene_name is None:
            return resolved

        v = variant.annotated.variant
        zone = self._nmd_lookup.escape_zone(gene_name, v.chrom, v.pos)
        if zone is None:
            return resolved

        if (
            zone.is_single_exon
            or zone.is_in_last_exon(v.pos)
            or zone.is_near_last_junction(v.pos, margin=50)
        ):
            return EvidenceTag.PVS1_STRONG
        return resolved

    def _evaluate_ps1(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        missing_sources: set[str],
    ) -> None:
        """Assign PS1 for same amino acid change as established pathogenic variant.

        PS1 fires when a different nucleotide change at the same codon
        produces the same amino acid substitution as a known ClinVar
        Pathogenic variant. Requires both the protein index and protein
        change annotation on the variant.
        """
        # PS1 only applies to missense variants
        if variant.annotated.consequence != FunctionalConsequence.MISSENSE:
            return

        protein_change = variant.annotated.protein_change
        if protein_change is None:
            # Missense but no codon resolution (no reference FASTA) — can't evaluate
            missing_sources.add("codon_resolution")
            return

        if self._protein_index is None or not self._protein_index.is_loaded:
            missing_sources.add("ClinVar_protein_index")
            return

        v = variant.annotated.variant
        if self._protein_index.check_ps1(
            gene=protein_change.gene_name,
            aa_position=protein_change.position,
            ref_aa=protein_change.reference_aa,
            alt_aa=protein_change.altered_aa,
            chrom=v.chrom,
            genomic_pos=v.pos,
            ref_allele=v.ref,
            alt_allele=v.alt,
        ):
            tags.add(EvidenceTag.PS1)

    def _evaluate_pm5(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        missing_sources: set[str],
    ) -> None:
        """Assign PM5 for novel missense at position with known pathogenic change.

        PM5 fires when the variant introduces a different amino acid change
        at a position where another missense change is already classified
        as Pathogenic. Does not fire if PS1 already assigned (PS1 is stronger
        and the same-change case subsumes the different-change case).
        """
        # PM5 only applies to missense variants
        if variant.annotated.consequence != FunctionalConsequence.MISSENSE:
            return

        protein_change = variant.annotated.protein_change
        if protein_change is None:
            missing_sources.add("codon_resolution")
            return

        if self._protein_index is None or not self._protein_index.is_loaded:
            missing_sources.add("ClinVar_protein_index")
            return

        # Don't double-count: if PS1 already fired, PM5 is redundant
        if EvidenceTag.PS1 in tags:
            return

        if self._protein_index.check_pm5(
            gene=protein_change.gene_name,
            aa_position=protein_change.position,
            ref_aa=protein_change.reference_aa,
            alt_aa=protein_change.altered_aa,
        ):
            tags.add(EvidenceTag.PM5)

    def _evaluate_pm2(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        missing_sources: set[str],
    ) -> None:
        """Assign PM2 when the variant is rare in consulted population data.

        PM2 encodes "absent from, or rare in, population controls", which
        requires the population database to have been consulted. Two inputs
        satisfy that: an observed allele frequency below the threshold in
        every available population, or ``frequency_unknown`` set, which the
        annotation stage sets when gnomAD was queried and returned no record
        for the variant.

        A variant with no frequency data and ``frequency_unknown`` not set
        was never consulted (annotation not run, or the lookup did not
        complete). That is the absence of evidence, not evidence of rarity,
        so PM2 does not fire and gnomAD is recorded as a missing source.
        """
        annotated = variant.annotated
        pop_freq = annotated.population_frequencies
        pm2_threshold = self._resolve_thresholds(variant).pm2_af

        if pop_freq is not None:
            has_any_data = any(
                v is not None
                for v in (
                    pop_freq.afr,
                    pop_freq.amr,
                    pop_freq.asj,
                    pop_freq.eas,
                    pop_freq.fin,
                    pop_freq.nfe,
                    pop_freq.sas,
                    pop_freq.global_af,
                )
            )
            if has_any_data:
                if pop_freq.all_below(pm2_threshold):
                    tags.add(EvidenceTag.PM2)
                return
            # Every per-population field is None. Only a confirmed gnomAD
            # miss (frequency_unknown) counts as absence; otherwise the data
            # was never obtained.
            if annotated.frequency_unknown:
                tags.add(EvidenceTag.PM2)
            else:
                missing_sources.add(_MISSING_SOURCE_GNOMAD)
            return

        af = annotated.allele_frequency
        if af is not None:
            if af < pm2_threshold:
                tags.add(EvidenceTag.PM2)
            return

        # No observed AF. A confirmed gnomAD miss is absence; a bare None
        # with no such confirmation is missing data.
        if annotated.frequency_unknown:
            tags.add(EvidenceTag.PM2)
        else:
            missing_sources.add(_MISSING_SOURCE_GNOMAD)

    def _evaluate_pp3(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        missing_sources: set[str],
    ) -> None:
        """Assign PP3 based on ClinGen-calibrated REVEL or SpliceAI thresholds.

        Strength-modulated per Pejaver et al. (2022):
        - PP3_Strong: REVEL > 0.932
        - PP3_Moderate: REVEL > 0.773
        - PP3 (supporting): REVEL > 0.644
        - PP3 (supporting): SpliceAI > 0.5 on splice-adjacent variant

        Only the highest applicable strength fires. When neither predictor
        is available, both are recorded as missing.
        """
        revel = variant.revel_score
        spliceai = variant.spliceai_score
        consequence = variant.annotated.consequence

        revel_available = revel is not None
        spliceai_available = spliceai is not None

        if not revel_available and not spliceai_available:
            missing_sources.add("REVEL")
            missing_sources.add("SpliceAI")
            return

        # Check REVEL at strong threshold first (highest bar = strongest evidence)
        if revel is not None and revel > _PP3_REVEL_STRONG_THRESHOLD:
            tags.add(EvidenceTag.PP3_STRONG)
            return

        # Then moderate threshold
        if revel is not None and revel > _PP3_REVEL_MODERATE_THRESHOLD:
            tags.add(EvidenceTag.PP3_MODERATE)
            return

        # Then supporting-level REVEL
        if revel is not None and revel > _PP3_REVEL_THRESHOLD:
            tags.add(EvidenceTag.PP3)
            return

        # SpliceAI-based PP3 (supporting only)
        splice_adjacent = consequence in _PP3_SPLICE_ADJACENT
        if (
            spliceai is not None
            and spliceai > _PP3_SPLICEAI_THRESHOLD
            and splice_adjacent
        ):
            tags.add(EvidenceTag.PP3)
            return

        if not revel_available:
            missing_sources.add("REVEL")
        if not spliceai_available:
            missing_sources.add("SpliceAI")

    def _evaluate_pp5(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        missing_sources: set[str],
    ) -> None:
        """Assign PP5 if ClinVar asserts Pathogenic without conflicts.

        PP5 is assigned when the ClinVar assertion is Pathogenic and
        there is no conflicting Benign or Likely_Benign assertion. If
        ClinVar data is unavailable (clinvar_unknown is True and
        assertion is None), PP5 is omitted and ClinVar is recorded as
        a missing data source.

        Parameters
        ----------
        variant : ScoredVariant
            The variant to evaluate.
        tags : set[EvidenceTag]
            Accumulator for assigned tags (mutated in place).
        missing_sources : set[str]
            Accumulator for missing data sources (mutated in place).
        """
        annotated = variant.annotated
        assertion = annotated.clinvar_assertion

        if assertion is None:
            missing_sources.add("ClinVar")
            return

        if assertion == ClinVarAssertion.PATHOGENIC:
            # Modulate PP5 strength by review status when the extended ClinVar
            # format supplied it. Without a review status (basic format), keep
            # the single-assertion supporting-level behavior.
            review_status = annotated.clinvar_review_status
            if review_status == ClinVarReviewStatus.EXPERT_PANEL:
                tags.add(EvidenceTag.PP5_STRONG)
            elif review_status in (
                ClinVarReviewStatus.NO_CRITERIA,
                ClinVarReviewStatus.CONFLICTING,
            ):
                # No assertion criteria (or conflicting) does not support PP5.
                return
            else:
                tags.add(EvidenceTag.PP5)
        elif assertion in _PP5_CONFLICTING_ASSERTIONS:
            # The assertion itself is Benign or Likely_Benign, so PP5
            # does not apply (this is the "conflicting" case).
            pass

    def _evaluate_ba1(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        _missing_sources: set[str],
    ) -> None:
        """Assign BA1 if any population AF exceeds 5%.

        BA1 is standalone benign evidence. If population-specific
        frequencies are available, checks each population. Falls back
        to global AF when per-population data is absent.
        """
        annotated = variant.annotated
        pop_freq = annotated.population_frequencies
        ba1_threshold = self._resolve_thresholds(variant).ba1_af

        if pop_freq is not None:
            if pop_freq.any_exceeds(ba1_threshold):
                tags.add(EvidenceTag.BA1)

        else:
            # Fallback to global AF
            af = annotated.allele_frequency
            if af is not None and af > ba1_threshold:
                tags.add(EvidenceTag.BA1)

    def _evaluate_bs1(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        _missing_sources: set[str],
    ) -> None:
        """Assign BS1 if any population AF exceeds 1%.

        Only fires when BA1 has not already been assigned (BA1 is
        stronger and subsumes BS1 in the combining rules).
        """
        if EvidenceTag.BA1 in tags:
            return

        annotated = variant.annotated
        pop_freq = annotated.population_frequencies
        bs1_threshold = self._resolve_thresholds(variant).bs1_af

        if pop_freq is not None:
            if pop_freq.any_exceeds(bs1_threshold):
                tags.add(EvidenceTag.BS1)

        else:
            af = annotated.allele_frequency
            if af is not None and af > bs1_threshold:
                tags.add(EvidenceTag.BS1)

    def _evaluate_bs2(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        missing_sources: set[str],
    ) -> None:
        """Assign BS2 for a variant observed homozygous in healthy controls.

        BS2 is strong benign evidence for a fully penetrant dominant
        disorder: a homozygous observation in gnomAD argues against
        pathogenicity. It fires only when the gnomAD homozygote count is
        available and positive AND the gene is associated with a dominant
        disorder. It does not fire for recessive disorders, where homozygous
        carriers are expected. When the homozygote count is unavailable, the
        source is recorded as missing rather than treated as absence.
        """
        annotated = variant.annotated
        gene_context = annotated.gene_context

        # BS2 only applies to dominant disorders. Without a dominant
        # association the criterion is not applicable, so the homozygote
        # count is not a required source and nothing is recorded missing.
        if gene_context is None or not gene_context.disease_associations:
            return

        is_dominant = any(
            (a.inheritance_mode or "").upper() == "AD"
            for a in gene_context.disease_associations
        )
        if not is_dominant:
            return

        # The gene is dominant, so BS2 is applicable and the homozygote count
        # is required. Record it as missing when unavailable.
        pop_freq = annotated.population_frequencies
        if pop_freq is None or pop_freq.hom_count is None:
            missing_sources.add("gnomAD_homozygotes")
            return

        if pop_freq.hom_count > 0:
            tags.add(EvidenceTag.BS2)

    def _evaluate_bp4(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        missing_sources: set[str],
    ) -> None:
        """Assign BP4 for computational benign evidence.

        Strength-modulated per Pejaver et al. (2022):
        - BP4_Moderate: REVEL < 0.183 (stronger benign evidence)
        - BP4 (supporting): REVEL < 0.290

        For missense variants without a REVEL score, a low CADD Phred
        (< 10) supports BP4, mirroring the CADD path used for other
        consequence classes and the fallback PP3 uses on the pathogenic
        side. When neither REVEL nor CADD is available for a missense
        variant, REVEL is recorded as a missing source.

        Does NOT fire for protein-altering variants (null variants or
        in-frame indels) where low computational scores are not
        appropriate evidence of benign impact. In-frame indels already
        fire PM4; awarding BP4 simultaneously would create contradictory
        evidence for the same variant.
        """
        consequence = variant.annotated.consequence

        # Null and protein-length-altering variants should not receive
        # computational benign evidence. In-frame indels get PM4 (moderate
        # pathogenic); BP4 on the same variant is logically contradictory.
        if consequence in (
            FunctionalConsequence.FRAMESHIFT,
            FunctionalConsequence.NONSENSE,
            FunctionalConsequence.STOP_LOSS,
            FunctionalConsequence.IN_FRAME_INSERTION,
            FunctionalConsequence.IN_FRAME_DELETION,
        ):
            return

        if consequence == FunctionalConsequence.MISSENSE:
            self._evaluate_bp4_missense(variant, tags, missing_sources)
        else:
            cadd = variant.cadd_phred
            if cadd is not None and cadd < _BP4_CADD_THRESHOLD:
                tags.add(EvidenceTag.BP4)

    def _evaluate_bp4_missense(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        missing_sources: set[str],
    ) -> None:
        revel = variant.revel_score
        if revel is not None:
            if revel < _BP4_REVEL_MODERATE_THRESHOLD:
                tags.add(EvidenceTag.BP4_MODERATE)
            elif revel < _BP4_REVEL_THRESHOLD:
                tags.add(EvidenceTag.BP4)
            return
        cadd = variant.cadd_phred
        if cadd is not None:
            if cadd < _BP4_CADD_THRESHOLD:
                tags.add(EvidenceTag.BP4)
            return
        missing_sources.add("REVEL")

    def _evaluate_bp7(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        _missing_sources: set[str],
    ) -> None:
        """Assign BP7 for synonymous variants with no splice impact.

        Fires when the variant is synonymous AND SpliceAI < 0.1
        (no predicted splice disruption).
        """
        if variant.annotated.consequence != FunctionalConsequence.SYNONYMOUS:
            return

        spliceai = variant.spliceai_score
        if spliceai is not None and spliceai < _BP7_SPLICEAI_THRESHOLD:
            tags.add(EvidenceTag.BP7)
        elif spliceai is None:
            # Without SpliceAI, we can't confirm no splice impact
            # BP7 requires negative splice evidence, so don't fire
            pass

    def _evaluate_pm1(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        missing_sources: set[str],
    ) -> None:
        """Assign PM1 for missense in a functionally constrained gene region.

        Uses gnomAD missense constraint (mis_z > 3.09) as a proxy for
        functional domain intolerance. Only fires for missense variants.
        """
        if variant.annotated.consequence != FunctionalConsequence.MISSENSE:
            return

        gene_context = variant.annotated.gene_context
        if gene_context is None or gene_context.constraint is None:
            missing_sources.add(_MISSING_SOURCE_GNOMAD_CONSTRAINT)
            return

        if gene_context.constraint.is_missense_constrained:
            tags.add(EvidenceTag.PM1)

    def _evaluate_pm4(
        self,
        variant: ScoredVariant,
        tags: set[EvidenceTag],
        _missing_sources: set[str],
    ) -> None:
        """Assign PM4 for protein-length-changing variants.

        Fires for in-frame insertions, in-frame deletions, and
        stop-loss variants. These alter the protein without truncating
        the reading frame.
        """
        if variant.annotated.consequence in _PM4_CONSEQUENCES:
            tags.add(EvidenceTag.PM4)

__init__(protein_index=None, lof_gene_list=None, nmd_lookup=None, use_disease_thresholds=False)

Initialize the classifier with optional protein-level ClinVar index.

Parameters

protein_index : Optional[ClinVarProteinIndex] Pre-loaded index of ClinVar pathogenic missense variants for PS1/PM5 evaluation. When None, PS1 and PM5 are omitted with the source recorded as missing. lof_gene_list : Optional[frozenset[str]] Explicit set of gene symbols where LoF is the established disease mechanism. When provided, PVS1 fires at Very Strong only for genes on this list. Genes not on the list get PVS1 at Strong (downgraded). When None, pLI-based gating is used. Gene names are matched case-sensitively against variant.annotated.gene_name (HGNC symbols, e.g., "BRCA1"). nmd_lookup : Optional[NMDLookup] Transcript CDS index exposing escape_zone(gene, chrom, pos). When provided, PVS1 is downgraded from Very Strong to Strong for null variants that escape nonsense-mediated decay (last exon, within 50 nt of the final junction, or a single-exon gene). When None, transcript_structure is recorded as missing and PVS1 keeps its current strength. use_disease_thresholds : bool When True, BA1/BS1/PM2 frequency gates are selected per variant from the gene's inheritance mode (via gene_context). When False (default), fixed thresholds are used for backward compatibility.

Source code in vartriage/classification/acmg.py
def __init__(
    self,
    protein_index: ClinVarProteinIndex | None = None,
    lof_gene_list: frozenset[str] | None = None,
    nmd_lookup: NMDLookup | None = None,
    use_disease_thresholds: bool = False,
) -> None:
    """Initialize the classifier with optional protein-level ClinVar index.

    Parameters
    ----------
    protein_index : Optional[ClinVarProteinIndex]
        Pre-loaded index of ClinVar pathogenic missense variants for
        PS1/PM5 evaluation. When None, PS1 and PM5 are omitted with
        the source recorded as missing.
    lof_gene_list : Optional[frozenset[str]]
        Explicit set of gene symbols where LoF is the established
        disease mechanism. When provided, PVS1 fires at Very Strong
        only for genes on this list. Genes not on the list get PVS1
        at Strong (downgraded). When None, pLI-based gating is used.
        Gene names are matched case-sensitively against
        ``variant.annotated.gene_name`` (HGNC symbols, e.g., "BRCA1").
    nmd_lookup : Optional[NMDLookup]
        Transcript CDS index exposing ``escape_zone(gene, chrom, pos)``.
        When provided, PVS1 is downgraded from Very Strong to Strong for
        null variants that escape nonsense-mediated decay (last exon,
        within 50 nt of the final junction, or a single-exon gene). When
        None, ``transcript_structure`` is recorded as missing and PVS1
        keeps its current strength.
    use_disease_thresholds : bool
        When True, BA1/BS1/PM2 frequency gates are selected per variant
        from the gene's inheritance mode (via ``gene_context``). When
        False (default), fixed thresholds are used for backward
        compatibility.
    """
    self._protein_index = protein_index
    self._lof_gene_list = lof_gene_list
    self._nmd_lookup = nmd_lookup
    self._use_disease_thresholds = use_disease_thresholds

classify(variants)

Assign evidence tags and classify each scored variant.

Evaluates ACMG/AMP 2015 evidence criteria for each variant, then applies combining rules to determine the final classification (Pathogenic, Likely_Pathogenic, or VUS).

Parameters

variants : Iterator[ScoredVariant] Stream of scored variants to classify.

Yields

ClassifiedVariant Each variant with evidence tags assigned, classification determined by ACMG/AMP 2015 combining rules, and missing data sources recorded.

Source code in vartriage/classification/acmg.py
def classify(
    self, variants: Iterator[ScoredVariant]
) -> Iterator[ClassifiedVariant]:
    """Assign evidence tags and classify each scored variant.

    Evaluates ACMG/AMP 2015 evidence criteria for each variant, then
    applies combining rules to determine the final classification
    (Pathogenic, Likely_Pathogenic, or VUS).

    Parameters
    ----------
    variants : Iterator[ScoredVariant]
        Stream of scored variants to classify.

    Yields
    ------
    ClassifiedVariant
        Each variant with evidence tags assigned, classification
        determined by ACMG/AMP 2015 combining rules, and missing
        data sources recorded.
    """
    for variant in variants:
        tags, missing_sources = self._assign_tags(variant)
        evidence = frozenset(tags)
        classification = combine_evidence(evidence)
        yield ClassifiedVariant(
            scored=variant,
            evidence_tags=evidence,
            classification=classification,
            missing_data_sources=frozenset(missing_sources),
            has_conflicting_evidence=has_conflicting_evidence(evidence),
        )