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:
Kemal Yaylali
2026-09-12 08:30:44 +01:00
parent abde5ec6e4
commit 07a01715fd
47 changed files with 2159 additions and 539 deletions
+101
View File
@@ -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]