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.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user