Skip to content

Commit 3cfe234

Browse files
committed
feat: implement evidence-based ACMG classification agent
Classification agent evaluates ACMG criteria from annotation evidence: - BA1: allele frequency > 5% → standalone benign - BS1: AF 1-5% → strong benign - PM2: AF < 0.0001 or absent from gnomAD → moderate pathogenic - PP5/BP6: ClinVar pathogenic/benign with 2+ star review - PP3/BP4: SIFT deleterious/tolerated + PolyPhen damaging/benign - PM1: variant in known protein domain Two-phase approach: 1. Deterministic criterion evaluation from database evidence 2. ACMG combining rules via acmg_engine.classify() Confidence scoring based on evidence completeness (data sources queried, criteria evaluated, ClinVar review quality). 12 new tests verify criterion evaluation and end-to-end classification (e.g., rare + ClinVar pathogenic + computational deleterious + domain = Likely Pathogenic). 87 total tests passing.
1 parent c033bac commit 3cfe234

2 files changed

Lines changed: 392 additions & 14 deletions

File tree

src/variantagent/agents/orchestrator.py

Lines changed: 229 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -447,46 +447,261 @@ def _run_literature_search_sync(variant: Variant) -> tuple[list, str | None]:
447447
def classification_node(state: AnalysisState) -> dict[str, Any]:
448448
"""Apply ACMG criteria and classify the variant.
449449
450-
TODO: Implement LLM-based criterion assessment + deterministic rule engine.
451-
Currently returns a placeholder VUS classification.
450+
Two-phase approach:
451+
1. Rule-based criterion assessment using annotation data (no LLM needed
452+
for criteria that can be evaluated deterministically from database evidence)
453+
2. Deterministic ACMG combining rules via acmg_engine.classify()
454+
455+
LLM-based criterion assessment for ambiguous criteria will be added
456+
once the rule-based approach is validated.
452457
"""
453458
start = time.time()
454459
variant = state["variant"]
460+
annotation = state["annotation"]
455461

456462
from variantagent.models.classification import (
457463
ACMGClassification,
458-
ACMGClassificationResult,
459464
ACMGCriteria,
465+
EvidenceCode,
466+
EvidenceDirection,
467+
EvidenceStrength,
460468
)
469+
from variantagent.tools.acmg_engine import classify as acmg_classify
470+
471+
criteria = ACMGCriteria()
472+
473+
# Evaluate criteria from annotation evidence (deterministic, no LLM)
474+
if annotation:
475+
criteria = _evaluate_criteria_from_evidence(variant, annotation)
476+
477+
# Run deterministic ACMG combining rules
478+
classification_result, rule_description = acmg_classify(criteria)
479+
480+
# Calculate confidence based on evidence completeness
481+
confidence = _calculate_confidence(annotation, criteria)
482+
483+
applied_codes = criteria.get_applied_codes()
484+
applied_summary = [c.code for c in applied_codes]
485+
486+
reasoning_parts = [f"Classification: {classification_result.value}"]
487+
reasoning_parts.append(f"Rule applied: {rule_description}")
488+
if applied_codes:
489+
reasoning_parts.append(f"Evidence codes: {', '.join(applied_summary)}")
490+
for code in applied_codes:
491+
reasoning_parts.append(f" {code.code}: {code.reasoning}")
492+
else:
493+
reasoning_parts.append("No ACMG evidence criteria were met from available data.")
461494

462-
# Placeholder classification — will be replaced with real ACMG assessment
463495
classification = ACMGClassification(
464-
classification=ACMGClassificationResult.VUS,
465-
criteria=ACMGCriteria(),
466-
confidence=0.5,
467-
reasoning="Placeholder — ACMG criterion assessment not yet implemented",
468-
applied_codes_summary=[],
469-
classification_rule="No evidence criteria met",
496+
classification=classification_result,
497+
criteria=criteria,
498+
confidence=confidence,
499+
reasoning="\n".join(reasoning_parts),
500+
applied_codes_summary=applied_summary,
501+
classification_rule=rule_description,
470502
)
471503

472504
duration_ms = int((time.time() - start) * 1000)
473505
provenance_entry = ProvenanceEntry(
474506
step=5,
475507
agent="classification_agent",
476508
action="ACMG classification",
477-
input_summary=f"Variant: {variant.variant_id}, annotation available: {state['annotation'] is not None}",
478-
output_summary=f"Classification: {classification.classification.value} "
479-
f"(confidence: {classification.confidence})",
509+
input_summary=f"Variant: {variant.variant_id}, "
510+
f"ClinVar: {annotation.clinvar.found if annotation else 'N/A'}, "
511+
f"gnomAD AF: {annotation.gnomad.overall_af if annotation and annotation.gnomad.found else 'N/A'}",
512+
output_summary=f"{classification_result.value} ({', '.join(applied_summary) or 'no criteria met'}) "
513+
f"confidence: {confidence:.2f}",
480514
duration_ms=duration_ms,
481515
)
482516

517+
logger.info(
518+
"Classification for %s: %s (confidence: %.2f, codes: %s)",
519+
variant.variant_id,
520+
classification_result.value,
521+
confidence,
522+
applied_summary,
523+
)
524+
483525
return {
484526
"classification": classification,
485-
"overall_confidence": classification.confidence,
527+
"overall_confidence": confidence,
486528
"provenance": [provenance_entry],
487529
}
488530

489531

532+
def _evaluate_criteria_from_evidence(
533+
variant: Variant,
534+
annotation: VariantAnnotation,
535+
) -> "ACMGCriteria":
536+
"""Evaluate ACMG criteria deterministically from annotation evidence.
537+
538+
These criteria can be assessed without LLM judgment — they depend on
539+
objective data from databases.
540+
"""
541+
from variantagent.models.classification import (
542+
ACMGCriteria,
543+
EvidenceCode,
544+
EvidenceDirection,
545+
EvidenceStrength,
546+
)
547+
548+
kwargs: dict[str, EvidenceCode] = {}
549+
550+
# --- BA1: Allele frequency > 5% (standalone benign) ---
551+
if annotation.gnomad.found and annotation.gnomad.overall_af is not None:
552+
if annotation.gnomad.overall_af > 0.05:
553+
kwargs["ba1"] = EvidenceCode(
554+
code="BA1", name="Allele frequency > 5%",
555+
direction=EvidenceDirection.BENIGN, strength=EvidenceStrength.VERY_STRONG,
556+
applied=True,
557+
reasoning=f"gnomAD overall AF = {annotation.gnomad.overall_af:.4f} (> 0.05 threshold)",
558+
data_source="gnomAD", confidence=0.99,
559+
)
560+
561+
# --- BS1: Allele frequency greater than expected for disorder ---
562+
if annotation.gnomad.found and annotation.gnomad.overall_af is not None:
563+
if 0.01 < annotation.gnomad.overall_af <= 0.05:
564+
kwargs["bs1"] = EvidenceCode(
565+
code="BS1", name="Allele frequency greater than expected",
566+
direction=EvidenceDirection.BENIGN, strength=EvidenceStrength.STRONG,
567+
applied=True,
568+
reasoning=f"gnomAD overall AF = {annotation.gnomad.overall_af:.4f} (> 0.01, suggesting common variant)",
569+
data_source="gnomAD", confidence=0.90,
570+
)
571+
572+
# --- PM2: Absent from population databases ---
573+
if annotation.gnomad.found and annotation.gnomad.overall_af is not None:
574+
if annotation.gnomad.overall_af < 0.0001:
575+
kwargs["pm2"] = EvidenceCode(
576+
code="PM2", name="Absent/extremely low frequency in population databases",
577+
direction=EvidenceDirection.PATHOGENIC, strength=EvidenceStrength.MODERATE,
578+
applied=True,
579+
reasoning=f"gnomAD overall AF = {annotation.gnomad.overall_af:.6f} (< 0.0001 threshold)",
580+
data_source="gnomAD", confidence=0.85,
581+
)
582+
elif annotation.gnomad.found is False:
583+
kwargs["pm2"] = EvidenceCode(
584+
code="PM2", name="Absent from population databases",
585+
direction=EvidenceDirection.PATHOGENIC, strength=EvidenceStrength.MODERATE,
586+
applied=True,
587+
reasoning="Variant not found in gnomAD — absent from population databases",
588+
data_source="gnomAD", confidence=0.80,
589+
)
590+
591+
# --- PP5: Reputable source reports pathogenic ---
592+
if annotation.clinvar.found and annotation.clinvar.clinical_significance:
593+
sig = annotation.clinvar.clinical_significance.lower()
594+
stars = annotation.clinvar.review_stars or 0
595+
596+
if "pathogenic" in sig and "likely" not in sig and stars >= 2:
597+
kwargs["pp5"] = EvidenceCode(
598+
code="PP5", name="Reputable source reports pathogenic",
599+
direction=EvidenceDirection.PATHOGENIC, strength=EvidenceStrength.SUPPORTING,
600+
applied=True,
601+
reasoning=f"ClinVar: '{annotation.clinvar.clinical_significance}' "
602+
f"({stars}-star review, {annotation.clinvar.submitter_count} submitters)",
603+
data_source="ClinVar", confidence=0.90,
604+
)
605+
elif "likely pathogenic" in sig and stars >= 2:
606+
kwargs["pp5"] = EvidenceCode(
607+
code="PP5", name="Reputable source reports likely pathogenic",
608+
direction=EvidenceDirection.PATHOGENIC, strength=EvidenceStrength.SUPPORTING,
609+
applied=True,
610+
reasoning=f"ClinVar: '{annotation.clinvar.clinical_significance}' ({stars}-star review)",
611+
data_source="ClinVar", confidence=0.80,
612+
)
613+
614+
# --- BP6: Reputable source reports benign ---
615+
if annotation.clinvar.found and annotation.clinvar.clinical_significance:
616+
sig = annotation.clinvar.clinical_significance.lower()
617+
stars = annotation.clinvar.review_stars or 0
618+
619+
if ("benign" in sig or "likely benign" in sig) and stars >= 2:
620+
kwargs["bp6"] = EvidenceCode(
621+
code="BP6", name="Reputable source reports benign",
622+
direction=EvidenceDirection.BENIGN, strength=EvidenceStrength.SUPPORTING,
623+
applied=True,
624+
reasoning=f"ClinVar: '{annotation.clinvar.clinical_significance}' ({stars}-star review)",
625+
data_source="ClinVar", confidence=0.90,
626+
)
627+
628+
# --- PP3: Computational evidence supports deleterious ---
629+
if annotation.ensembl_vep.found:
630+
sift_del = annotation.ensembl_vep.sift_prediction == "deleterious"
631+
polyphen_dam = annotation.ensembl_vep.polyphen_prediction in (
632+
"probably_damaging", "possibly_damaging"
633+
)
634+
if sift_del and polyphen_dam:
635+
kwargs["pp3"] = EvidenceCode(
636+
code="PP3", name="Computational evidence supports deleterious",
637+
direction=EvidenceDirection.PATHOGENIC, strength=EvidenceStrength.SUPPORTING,
638+
applied=True,
639+
reasoning=f"SIFT: {annotation.ensembl_vep.sift_prediction} "
640+
f"(score: {annotation.ensembl_vep.sift_score}), "
641+
f"PolyPhen: {annotation.ensembl_vep.polyphen_prediction} "
642+
f"(score: {annotation.ensembl_vep.polyphen_score})",
643+
data_source="Ensembl VEP", confidence=0.70,
644+
)
645+
646+
# --- BP4: Computational evidence supports benign ---
647+
if annotation.ensembl_vep.found:
648+
sift_tol = annotation.ensembl_vep.sift_prediction == "tolerated"
649+
polyphen_ben = annotation.ensembl_vep.polyphen_prediction == "benign"
650+
if sift_tol and polyphen_ben:
651+
kwargs["bp4"] = EvidenceCode(
652+
code="BP4", name="Computational evidence supports benign",
653+
direction=EvidenceDirection.BENIGN, strength=EvidenceStrength.SUPPORTING,
654+
applied=True,
655+
reasoning=f"SIFT: tolerated (score: {annotation.ensembl_vep.sift_score}), "
656+
f"PolyPhen: benign (score: {annotation.ensembl_vep.polyphen_score})",
657+
data_source="Ensembl VEP", confidence=0.70,
658+
)
659+
660+
# --- PM1: Located in mutational hot spot / functional domain ---
661+
if annotation.ensembl_vep.found and annotation.ensembl_vep.protein_domain:
662+
kwargs["pm1"] = EvidenceCode(
663+
code="PM1", name="Located in mutational hot spot / functional domain",
664+
direction=EvidenceDirection.PATHOGENIC, strength=EvidenceStrength.MODERATE,
665+
applied=True,
666+
reasoning=f"Variant falls in protein domain: {annotation.ensembl_vep.protein_domain}",
667+
data_source="Ensembl VEP", confidence=0.65,
668+
)
669+
670+
return ACMGCriteria(**kwargs)
671+
672+
673+
def _calculate_confidence(
674+
annotation: VariantAnnotation | None,
675+
criteria: "ACMGCriteria",
676+
) -> float:
677+
"""Calculate confidence score based on evidence completeness.
678+
679+
Higher confidence when more data sources contributed evidence.
680+
"""
681+
if annotation is None:
682+
return 0.2
683+
684+
score = 0.3 # Base confidence
685+
686+
# Bonus for each data source that returned results
687+
if annotation.clinvar.found:
688+
score += 0.15
689+
if annotation.gnomad.found:
690+
score += 0.15
691+
if annotation.ensembl_vep.found:
692+
score += 0.10
693+
694+
# Bonus for number of criteria evaluated
695+
applied = criteria.get_applied_codes()
696+
score += min(len(applied) * 0.05, 0.20)
697+
698+
# Bonus for ClinVar review quality
699+
if annotation.clinvar.found and (annotation.clinvar.review_stars or 0) >= 2:
700+
score += 0.10
701+
702+
return min(score, 1.0)
703+
704+
490705
def review_node(state: AnalysisState) -> dict[str, Any]:
491706
"""Self-evaluation: cross-check conclusions and detect contradictions.
492707

0 commit comments

Comments
 (0)