"""Run VariantLens concordance validation and write an auditable JSON report. Validation mode (`--validation`) flips the engine into the more permissive ClinGen Bayesian + PP5/BP6-enabled configuration that ClinVar curators effectively use. The clinical default (strict Table 5, deprecated PP5/BP6 off) is correct for production but isn't directly comparable to ClinVar's expert-panel calls. """ from __future__ import annotations import argparse import asyncio import json import os import time from collections import Counter from datetime import UTC, datetime from pathlib import Path from typing import Any DEFAULT_FIXTURE = Path("backend/tests/fixtures/clinvar_validation_set.json") DEFAULT_OUT = Path("docs/clinical_validation_results.json") TARGET_CONCORDANCE = 0.85 ALLOW_ADJACENT = True PARTITION = { "Pathogenic": {"Pathogenic", "Likely Pathogenic"} if ALLOW_ADJACENT else {"Pathogenic"}, "Likely Pathogenic": {"Pathogenic", "Likely Pathogenic"} if ALLOW_ADJACENT else {"Likely Pathogenic"}, "Uncertain Significance": {"Uncertain Significance"}, "Likely Benign": {"Benign", "Likely Benign"} if ALLOW_ADJACENT else {"Likely Benign"}, "Benign": {"Benign", "Likely Benign"} if ALLOW_ADJACENT else {"Benign"}, } def _expected_to_canonical(value: str) -> str: table = { "Pathogenic": "Pathogenic", "Likely pathogenic": "Likely Pathogenic", "Likely Pathogenic": "Likely Pathogenic", "Uncertain significance": "Uncertain Significance", "Uncertain Significance": "Uncertain Significance", "Likely benign": "Likely Benign", "Likely Benign": "Likely Benign", "Benign": "Benign", } return table.get((value or "").strip(), value) def _extract_hgvs(title: str) -> str | None: if "(" not in title or ":" not in title: return None transcript = title.split("(", 1)[0].strip() coding = title.split(":", 1)[1].split(" ", 1)[0].rstrip(",") return f"{transcript}:{coding}" async def run_validation(limit: int | None, skip_rag: bool, fixture: Path) -> dict[str, Any]: # Imported here so any env override applied in main() (e.g. --validation) # is read before Settings is constructed. from backend.app.api.pipeline import VariantPipeline from backend.app.schemas.variant import VariantInput rows = json.loads(fixture.read_text()) pipeline = VariantPipeline() results: list[dict[str, Any]] = [] confusion: Counter[str] = Counter() correct = 0 total = 0 started = time.time() for index, row in enumerate(rows[:limit], start=1): hgvs = _extract_hgvs(row.get("title", "")) expected = _expected_to_canonical(row.get("expected_classification", "")) if not hgvs or expected not in PARTITION: continue row_started = time.time() try: result = await pipeline.run( VariantInput(raw=hgvs, gene_symbol=row.get("gene")), skip_rag=skip_rag, ) got: str = result.classification.significance rationale = result.classification.rationale criteria = [ { "code": c.code, "triggered": c.triggered, "strength": c.strength, "source": c.source, "evidence_text": c.evidence_text, "confidence": c.confidence, "pmid": c.pmid, "caveat": c.caveat, } for c in result.evidence.criteria ] error = None except Exception as exc: got = "ERROR" rationale = None criteria = [] error = str(exc) elapsed = round(time.time() - row_started, 3) match = got in PARTITION.get(expected, set()) if got != "ERROR": total += 1 if match: correct += 1 confusion[f"{expected} -> {got}"] += 1 print(f"{index:03d} {hgvs} expected={expected} got={got} match={match} elapsed={elapsed}s") results.append({ "variation_id": row.get("variation_id"), "gene": row.get("gene"), "hgvs": hgvs, "expected": expected, "got": got, "match": match, "elapsed_seconds": elapsed, "rationale": rationale, "criteria": criteria, "error": error, }) concordance = correct / total if total else 0.0 return { "generated_at": datetime.now(UTC).isoformat(), "fixture": str(fixture), "skip_rag": skip_rag, "target_concordance": TARGET_CONCORDANCE, "total_scored": total, "correct": correct, "concordance": concordance, "passed_target": concordance >= TARGET_CONCORDANCE, "elapsed_seconds": round(time.time() - started, 3), "confusion": dict(confusion), "results": results, } def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--limit", type=int) parser.add_argument("--skip-rag", action="store_true") parser.add_argument("--out", type=Path, default=DEFAULT_OUT) parser.add_argument( "--fixture", type=Path, default=DEFAULT_FIXTURE, help="Path to the ClinVar fixture JSON (default: 100-variant set).", ) parser.add_argument( "--validation", action="store_true", help="Use validation-mode config (Bayesian combiner + PP5/BP6 enabled).", ) args = parser.parse_args() if args.validation: os.environ["ACMG_COMBINER_STRATEGY"] = "bayesian" os.environ["ENABLE_DEPRECATED_CLINVAR_CRITERIA"] = "true" # Settings is lru_cached — clear so subsequent imports see the override try: from backend.app.config import get_settings get_settings.cache_clear() except Exception: pass report = asyncio.run(run_validation( limit=args.limit, skip_rag=args.skip_rag, fixture=args.fixture, )) args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(json.dumps(report, indent=2) + "\n") print( f"Concordance: {report['correct']}/{report['total_scored']} = " f"{report['concordance']:.1%}; wrote {args.out}" ) return 0 if report["passed_target"] else 1 if __name__ == "__main__": raise SystemExit(main())