Files
rarelens/api/app/services/triage.py
T
Kemal Yaylali 07a01715fd feat: redesign around phenotype-driven triage, not variant filtering
A table with filters made the user do the work. Rare disease triage is a different task:
which few variants could explain *this* patient's phenotype, and why. The app now answers
that, and lets a reviewer act on the answer.

Domain
- a case is a proband: a VCF plus the HPO terms observed in the patient (samples -> cases)
- HPO's gene-to-phenotype annotations are loaded as reference data (scripts/load-hpo.py)
- each candidate can be shortlisted or dismissed with a reason and a note

Ranking (app/services/triage.py, 21 tests)
- weighted sum of phenotype match, rarity, consequence severity and the model's score,
  with every component shown next to the candidate
- rarity and consequence filter; phenotype only ranks, because a real diagnosis can sit in
  a gene nobody has annotated yet and filtering on it would hide exactly that case
- ClinVar is deliberately not an input: it appears beside the result as independent
  confirmation, so nothing ranks highly merely because ClinVar already said pathogenic

UI
- the funnel is the headline: variants called -> rare -> coding candidates -> phenotype-matched
- ranked candidates with evidence chips, not a grid of everything; filters are demoted
- a variant panel showing the score breakdown, the matched HPO terms, the raw VEP record and
  links out to Ensembl/gnomAD/ClinVar, with the decision controls
- a printable case report: phenotype, funnel, shortlisted variants with reasons, provenance

API: /cases with phenotypes, /cases/{id}/candidates (funnel + ranked + weights),
/variants/{id}, /variants/{id}/decision, /cases/{id}/report, /phenotypes for the picker.
Scoring moved under the case and now answers 503 with the reason when no model registry is
reachable, instead of a 500.

Verified end to end on a simulated proband (scripts/make-demo-case.sh: real GIAB HG002
background + one real ClinVar 2-star pathogenic NF2 variant). 13 variants called -> 1 coding
candidate, and the planted variant ranks first at 0.80 on phenotype 1.00, rarity 1.00 and
consequence 1.00, with ClinVar agreeing afterwards.

Tests: api 75, ml 18, loader 16, web 27; ruff, mypy, svelte-check, terraform validate, both
kustomize overlays and the Nextflow stub run all clean.
2026-09-12 08:30:44 +01:00

107 lines
3.7 KiB
Python

"""Narrow a case's variants the way a clinical scientist does, and say why.
The rank is a weighted sum of four parts a reviewer can audit. ClinVar is deliberately not one of
them: it is shown beside the result as independent confirmation, so a variant never ranks highly
merely because ClinVar already called it pathogenic.
Rarity and consequence *filter* (the usual first pass); phenotype only *ranks*, because a real
diagnosis can sit in a gene nobody has annotated yet and filtering on it would hide exactly that.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from app.models import Variant
WEIGHTS = {"phenotype": 0.35, "rarity": 0.25, "consequence": 0.20, "model": 0.20}
RARE_AF = 0.001
CANDIDATE_IMPACTS = frozenset({"HIGH", "MODERATE"})
IMPACT_SEVERITY = {"HIGH": 1.0, "MODERATE": 0.6, "LOW": 0.2, "MODIFIER": 0.0}
# Allele frequency ceiling -> score, rarest first.
RARITY_STEPS = ((0.0, 1.0), (0.0001, 0.8), (0.001, 0.5), (0.01, 0.2))
@dataclass(frozen=True)
class Funnel:
"""How many variants survive each narrowing step; the headline of the case page."""
total: int
rare: int
candidates: int
phenotype_matched: int
@dataclass(frozen=True)
class Candidate:
variant: Variant
score: float
components: dict[str, float]
matched_terms: list[str]
scored: bool
def rarity_score(af: float | None) -> float:
if af is None: # absent from gnomAD
return 1.0
for ceiling, score in RARITY_STEPS:
if af <= ceiling:
return score
return 0.0
def consequence_score(impact: str | None) -> float:
return IMPACT_SEVERITY.get(impact or "", 0.0)
def phenotype_score(
gene: str | None, case_terms: Sequence[str], gene_terms: Mapping[str, set[str]]
) -> tuple[float, list[str]]:
"""What fraction of the patient's terms HPO associates with this gene, and which ones."""
if not gene or not case_terms:
return 0.0, []
annotated = gene_terms.get(gene, set())
matched = [term for term in case_terms if term in annotated]
return len(matched) / len(case_terms), matched
def is_rare(variant: Variant) -> bool:
return variant.gnomad_af is None or variant.gnomad_af < RARE_AF
def is_candidate(variant: Variant) -> bool:
return is_rare(variant) and variant.impact in CANDIDATE_IMPACTS
def funnel(
variants: Sequence[Variant], case_terms: Sequence[str], gene_terms: Mapping[str, set[str]]
) -> Funnel:
rare = [v for v in variants if is_rare(v)]
candidates = [v for v in rare if v.impact in CANDIDATE_IMPACTS]
matched = sum(1 for v in candidates if phenotype_score(v.gene, case_terms, gene_terms)[1])
return Funnel(len(variants), len(rare), len(candidates), matched)
def evaluate(
variant: Variant, case_terms: Sequence[str], gene_terms: Mapping[str, set[str]]
) -> Candidate:
"""Score one variant, whether or not it survived the filters."""
phenotype, matched = phenotype_score(variant.gene, case_terms, gene_terms)
prediction = variant.prediction
components = {
"phenotype": phenotype,
"rarity": rarity_score(variant.gnomad_af),
"consequence": consequence_score(variant.impact),
"model": float(prediction.score) if prediction is not None else 0.0,
}
score = sum(WEIGHTS[name] * value for name, value in components.items())
return Candidate(variant, score, components, matched, prediction is not None)
def rank(
variants: Sequence[Variant], case_terms: Sequence[str], gene_terms: Mapping[str, set[str]]
) -> list[Candidate]:
candidates = [evaluate(v, case_terms, gene_terms) for v in variants if is_candidate(v)]
# id breaks ties, so equal scores do not shuffle between requests.
candidates.sort(key=lambda c: (-c.score, c.variant.id))
return candidates