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.
102 lines
4.0 KiB
Python
102 lines
4.0 KiB
Python
"""Ranking is the scientific claim this app makes, so it is tested as pure logic."""
|
|
import pytest
|
|
|
|
from app.models import Prediction, Variant
|
|
from app.services import triage
|
|
|
|
|
|
def variant(**kw: object) -> Variant:
|
|
fields: dict = {
|
|
"id": 1, "chrom": "22", "pos": 100, "ref": "A", "alt": "G",
|
|
"gene": "NF2", "impact": "HIGH", "consequence": "frameshift_variant",
|
|
"gnomad_af": None, "clinvar_sig": None, "annotations": {},
|
|
}
|
|
fields.update(kw)
|
|
score = fields.pop("score", None)
|
|
v = Variant(**fields)
|
|
if score is not None:
|
|
v.prediction = Prediction(model_name="m", model_version="1", score=float(score))
|
|
return v
|
|
|
|
|
|
def test_weights_sum_to_one() -> None:
|
|
assert sum(triage.WEIGHTS.values()) == pytest.approx(1.0)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("af", "expected"),
|
|
[(None, 1.0), (0.0, 1.0), (0.00005, 0.8), (0.0005, 0.5), (0.005, 0.2), (0.05, 0.0)],
|
|
)
|
|
def test_rarity_rewards_absence_from_gnomad(af: float | None, expected: float) -> None:
|
|
assert triage.rarity_score(af) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("impact", "expected"),
|
|
[("HIGH", 1.0), ("MODERATE", 0.6), ("LOW", 0.2), ("MODIFIER", 0.0), (None, 0.0), ("?", 0.0)],
|
|
)
|
|
def test_consequence_severity(impact: str | None, expected: float) -> None:
|
|
assert triage.consequence_score(impact) == expected
|
|
|
|
|
|
def test_phenotype_match_is_the_fraction_of_the_patients_terms() -> None:
|
|
gene_terms = {"NF2": {"HP:0000365", "HP:0009592"}}
|
|
case_terms = ["HP:0000365", "HP:0009592", "HP:0002321", "HP:0000598"]
|
|
score, matched = triage.phenotype_score("NF2", case_terms, gene_terms)
|
|
assert score == 0.5
|
|
assert matched == ["HP:0000365", "HP:0009592"]
|
|
|
|
|
|
def test_phenotype_match_is_zero_for_genes_hpo_has_never_annotated() -> None:
|
|
assert triage.phenotype_score("NOVEL1", ["HP:0000365"], {}) == (0.0, [])
|
|
|
|
|
|
def test_phenotype_match_is_zero_when_no_phenotype_was_entered() -> None:
|
|
assert triage.phenotype_score("NF2", [], {"NF2": {"HP:0000365"}}) == (0.0, [])
|
|
|
|
|
|
def test_the_funnel_counts_each_narrowing_step() -> None:
|
|
variants = [
|
|
variant(id=1, gnomad_af=None, impact="HIGH", gene="NF2"), # rare, coding, matched
|
|
variant(id=2, gnomad_af=0.0002, impact="MODERATE", gene="CHEK2"), # rare, coding
|
|
variant(id=3, gnomad_af=0.3, impact="HIGH", gene="NF2"), # common
|
|
variant(id=4, gnomad_af=None, impact="MODIFIER", gene="NF2"), # rare, non-coding
|
|
]
|
|
funnel = triage.funnel(variants, case_terms=["HP:0000365"], gene_terms={"NF2": {"HP:0000365"}})
|
|
assert (funnel.total, funnel.rare, funnel.candidates, funnel.phenotype_matched) == (4, 3, 2, 1)
|
|
|
|
|
|
def test_the_diagnosis_outranks_the_noise() -> None:
|
|
gene_terms = {"NF2": {"HP:0000365", "HP:0009592"}}
|
|
case_terms = ["HP:0000365", "HP:0009592"]
|
|
diagnosis = variant(id=1, gene="NF2", impact="HIGH", gnomad_af=None, score=0.94)
|
|
plausible = variant(id=2, gene="CHEK2", impact="MODERATE", gnomad_af=0.0004, score=0.55)
|
|
noise = variant(id=3, gene="TTN", impact="MODERATE", gnomad_af=0.0009, score=0.10)
|
|
|
|
ranked = triage.rank([noise, plausible, diagnosis], case_terms, gene_terms)
|
|
assert [c.variant.id for c in ranked] == [1, 2, 3]
|
|
top = ranked[0]
|
|
assert top.matched_terms == case_terms
|
|
assert top.components["phenotype"] == 1.0
|
|
assert top.score == pytest.approx(0.35 + 0.25 + 0.20 + 0.20 * 0.94)
|
|
|
|
|
|
def test_an_unscored_variant_still_ranks_and_says_so() -> None:
|
|
[candidate] = triage.rank([variant(id=1, gnomad_af=None)], [], {})
|
|
assert candidate.components["model"] == 0.0
|
|
assert candidate.scored is False
|
|
|
|
|
|
def test_common_and_non_coding_variants_are_not_candidates() -> None:
|
|
variants = [
|
|
variant(id=1, gnomad_af=0.2, impact="HIGH"),
|
|
variant(id=2, gnomad_af=None, impact="MODIFIER"),
|
|
]
|
|
assert triage.rank(variants, [], {}) == []
|
|
|
|
|
|
def test_ranking_is_deterministic_for_equal_scores() -> None:
|
|
a = variant(id=7, gene="AAA", chrom="1", pos=10, gnomad_af=None)
|
|
b = variant(id=3, gene="BBB", chrom="1", pos=10, gnomad_af=None)
|
|
assert [c.variant.id for c in triage.rank([a, b], [], {})] == [3, 7]
|