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
+2 -1
View File
@@ -70,7 +70,8 @@ async def db(migrated_db: None) -> AsyncIterator[None]:
async with engine.begin() as conn:
await conn.execute(
text("TRUNCATE samples, jobs, variants, predictions RESTART IDENTITY CASCADE")
text("TRUNCATE cases, case_phenotypes, gene_phenotypes, jobs, variants, "
"predictions, variant_decisions RESTART IDENTITY CASCADE")
)
yield
+40 -13
View File
@@ -2,20 +2,47 @@ import uuid
from typing import Any
from app.db import SessionLocal
from app.models import Job, JobStatus, Sample, Variant
from app.models import Case, CasePhenotype, GenePhenotype, Job, JobStatus, Prediction, Variant
VARIANT_DEFAULTS: dict[str, Any] = {
"chrom": "22", "pos": 1, "ref": "A", "alt": "G", "gene": "NF2",
"impact": "HIGH", "consequence": "frameshift_variant", "gnomad_af": None, "annotations": {},
}
async def seed_job(
variants: list[dict[str, Any]], status: JobStatus = JobStatus.succeeded
) -> uuid.UUID:
"""Insert a sample, a job and its variants; each variant dict overrides the defaults."""
async def seed_case(
*,
phenotypes: list[tuple[str, str]] | None = None,
variants: list[dict[str, Any]] | None = None,
gene_terms: dict[str, list[tuple[str, str]]] | None = None,
status: JobStatus = JobStatus.succeeded,
name: str | None = None,
) -> tuple[uuid.UUID, uuid.UUID]:
"""Insert a case, its phenotypes, a job and its variants. Returns (case_id, job_id).
`variants` entries override VARIANT_DEFAULTS; a "score" key becomes a Prediction.
"""
async with SessionLocal() as s:
sample = Sample(name=f"s-{uuid.uuid4()}", vcf_uri="gs://b/x.vcf.gz", assembly="GRCh38")
job = Job(sample=sample, status=status)
rows = [
Variant(job=job, **{"chrom": "22", "pos": 1, "ref": "A", "alt": "G", "annotations": {}} | v)
for v in variants
]
s.add_all([sample, job, *rows])
case = Case(
name=name or f"case-{uuid.uuid4()}",
vcf_uri="gs://bucket/proband.vcf.gz",
assembly="GRCh38",
phenotypes=[CasePhenotype(hpo_id=hpo, label=label) for hpo, label in (phenotypes or [])],
)
job = Job(case=case, status=status, vep_version="113.0")
s.add_all([case, job])
for gene, terms in (gene_terms or {}).items():
s.add_all(
GenePhenotype(gene_symbol=gene, hpo_id=hpo, hpo_name=label) for hpo, label in terms
)
for spec in variants or []:
fields = VARIANT_DEFAULTS | spec
score = fields.pop("score", None)
variant = Variant(job=job, **fields)
s.add(variant)
if score is not None:
await s.flush()
s.add(Prediction(variant_id=variant.id, model_name="rarelens-pathogenicity",
model_version="demo", score=float(score)))
await s.commit()
return job.id
return case.id, job.id
+18 -18
View File
@@ -8,9 +8,9 @@ from app.config import settings
from app.services import events
async def new_sample(client: AsyncClient) -> str:
async def new_case(client: AsyncClient) -> str:
r = await client.post(
"/api/samples",
"/api/cases",
json={"name": f"s-{uuid.uuid4()}", "vcf_uri": "gs://bucket/x.vcf.gz", "assembly": "GRCh37"},
)
assert r.status_code == 201, r.text
@@ -23,9 +23,9 @@ async def test_local_without_nextflow_fails_fast_with_instructions(
) -> None:
monkeypatch.setattr(settings, "pubsub_topic", None)
monkeypatch.setattr(events.shutil, "which", lambda _: None)
sample_id = await new_sample(client)
case_id = await new_case(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
r = await client.post(f"/api/cases/{case_id}/annotate")
assert r.status_code == 202
job = r.json()
assert job["status"] == "failed"
@@ -71,9 +71,9 @@ async def test_local_run_is_watched_and_a_crash_marks_the_job_failed(
return FakeProcess(1, b"ERROR ~ VEP cache not found")
monkeypatch.setattr(events.asyncio, "create_subprocess_exec", fake_exec)
sample_id = await new_sample(client)
case_id = await new_case(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
r = await client.post(f"/api/cases/{case_id}/annotate")
assert r.status_code == 202
await events.drain()
@@ -117,9 +117,9 @@ async def test_pubsub_publishes_to_the_full_topic_path(
monkeypatch.setattr(settings, "pubsub_topic", "vcf-uploaded")
monkeypatch.setattr(settings, "gcp_project", "my-proj")
monkeypatch.setattr(events, "_publisher", lambda: publisher)
sample_id = await new_sample(client)
case_id = await new_case(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
r = await client.post(f"/api/cases/{case_id}/annotate")
job = r.json()
assert (job["status"], job["workflow_ref"]) == ("running", "pubsub:msg-123")
[(topic, data)] = publisher.published
@@ -134,9 +134,9 @@ async def test_pubsub_failure_marks_the_job_failed(
monkeypatch.setattr(settings, "pubsub_topic", "vcf-uploaded")
monkeypatch.setattr(settings, "gcp_project", "my-proj")
monkeypatch.setattr(events, "_publisher", lambda: FakePublisher(RuntimeError("403 denied")))
sample_id = await new_sample(client)
case_id = await new_case(client)
job = (await client.post(f"/api/samples/{sample_id}/annotate")).json()
job = (await client.post(f"/api/cases/{case_id}/annotate")).json()
assert job["status"] == "failed"
assert "403 denied" in job["log"]
@@ -159,9 +159,9 @@ async def test_the_pipeline_gets_its_own_database_url(
return FakeProcess(0, b"")
monkeypatch.setattr(events.asyncio, "create_subprocess_exec", fake_exec)
sample_id = await new_sample(client)
case_id = await new_case(client)
await client.post(f"/api/samples/{sample_id}/annotate")
await client.post(f"/api/cases/{case_id}/annotate")
await events.drain()
assert launched["env"]["DATABASE_URL"] == "postgresql+asyncpg://u:[email protected]:5432/db"
@@ -183,9 +183,9 @@ async def test_progress_is_recorded_while_the_pipeline_runs(
])
monkeypatch.setattr(events.asyncio, "create_subprocess_exec", fake_exec)
sample_id = await new_sample(client)
case_id = await new_case(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
r = await client.post(f"/api/cases/{case_id}/annotate")
await events.drain()
job = (await client.get(f"/api/jobs/{r.json()['id']}")).json()
@@ -196,12 +196,12 @@ async def test_progress_is_recorded_while_the_pipeline_runs(
@pytest.mark.usefixtures("db")
async def test_progress_never_overwrites_a_finished_job(client: AsyncClient) -> None:
from app.db import SessionLocal
from app.models import Job, JobStatus, Sample
from app.models import Case, Job, JobStatus
async with SessionLocal() as s:
sample = Sample(name=f"s-{uuid.uuid4()}", vcf_uri="gs://b/x.vcf.gz", assembly="GRCh38")
job = Job(sample=sample, status=JobStatus.succeeded)
s.add_all([sample, job])
case = Case(name=f"c-{uuid.uuid4()}", vcf_uri="gs://bucket/x.vcf.gz", assembly="GRCh38")
job = Job(case=case, status=JobStatus.succeeded)
s.add_all([case, job])
await s.commit()
job_id = job.id
+137
View File
@@ -0,0 +1,137 @@
"""The triage workflow: a case with a phenotype, ranked candidates, decisions, a report."""
import uuid
import pytest
from factories import seed_case
from httpx import AsyncClient
NF2_TERMS = [("HP:0000365", "Hearing impairment"), ("HP:0009592", "Vestibular schwannoma")]
CASE_TERMS = [*NF2_TERMS, ("HP:0002321", "Vertigo")]
async def a_case_with_candidates() -> tuple[uuid.UUID, uuid.UUID]:
return await seed_case(
phenotypes=CASE_TERMS,
gene_terms={"NF2": NF2_TERMS},
variants=[
{"id": 1, "gene": "NF2", "pos": 1000, "impact": "HIGH", "score": 0.94,
"clinvar_sig": "pathogenic"},
{"id": 2, "gene": "CHEK2", "pos": 2000, "impact": "MODERATE", "gnomad_af": 0.0004,
"score": 0.55},
{"id": 3, "gene": "TTN", "pos": 3000, "impact": "MODIFIER", "gnomad_af": None},
{"id": 4, "gene": "APOE", "pos": 4000, "impact": "HIGH", "gnomad_af": 0.3},
],
)
@pytest.mark.usefixtures("db")
async def test_a_case_keeps_its_phenotype(client: AsyncClient) -> None:
body = {
"name": "PROBAND-01",
"vcf_uri": "gs://bucket/proband.vcf.gz",
"phenotypes": [{"hpo_id": h, "label": lab} for h, lab in NF2_TERMS],
}
r = await client.post("/api/cases", json=body)
assert r.status_code == 201, r.text
assert [p["label"] for p in r.json()["phenotypes"]] == [lab for _, lab in NF2_TERMS]
listed = (await client.get("/api/cases")).json()
assert listed[0]["name"] == "PROBAND-01"
assert listed[0]["shortlisted"] == 0
@pytest.mark.usefixtures("db")
async def test_the_funnel_shows_the_narrowing(client: AsyncClient) -> None:
case_id, _ = await a_case_with_candidates()
page = (await client.get(f"/api/cases/{case_id}/candidates")).json()
# 4 variants -> 3 rare -> 2 rare and coding -> 1 of those in a phenotype-matched gene
assert page["funnel"] == {"total": 4, "rare": 3, "candidates": 2, "phenotype_matched": 1}
@pytest.mark.usefixtures("db")
async def test_the_phenotype_matched_variant_ranks_first_with_its_reasons(
client: AsyncClient,
) -> None:
case_id, _ = await a_case_with_candidates()
page = (await client.get(f"/api/cases/{case_id}/candidates")).json()
assert [c["variant"]["gene"] for c in page["items"]] == ["NF2", "CHEK2"]
top = page["items"][0]
assert [t["label"] for t in top["matched_terms"]] == ["Hearing impairment", "Vestibular schwannoma"]
assert top["components"]["phenotype"] == pytest.approx(2 / 3, abs=1e-4)
assert top["components"]["rarity"] == 1.0
assert page["weights"]["phenotype"] == 0.35
# ClinVar is evidence, not an input to the rank.
assert top["variant"]["clinvar_sig"] == "pathogenic"
@pytest.mark.usefixtures("db")
async def test_a_reviewer_decides_and_the_decision_sticks(client: AsyncClient) -> None:
case_id, _ = await a_case_with_candidates()
variant_id = (await client.get(f"/api/cases/{case_id}/candidates")).json()["items"][0]["variant"]["id"]
r = await client.post(
f"/api/variants/{variant_id}/decision",
json={"state": "shortlisted", "reason": "fits the phenotype", "note": "confirm by Sanger"},
)
assert r.status_code == 200, r.text
# Changing your mind replaces the decision rather than failing.
r = await client.post(f"/api/variants/{variant_id}/decision", json={"state": "dismissed"})
assert r.status_code == 200
detail = (await client.get(f"/api/variants/{variant_id}")).json()
assert detail["decision"]["state"] == "dismissed"
assert (await client.get("/api/cases")).json()[0]["shortlisted"] == 0
@pytest.mark.usefixtures("db")
async def test_the_variant_panel_carries_the_evidence(client: AsyncClient) -> None:
case_id, _ = await a_case_with_candidates()
variant_id = (await client.get(f"/api/cases/{case_id}/candidates")).json()["items"][0]["variant"]["id"]
detail = (await client.get(f"/api/variants/{variant_id}")).json()
assert detail["variant"]["hgvsp"] is None or isinstance(detail["variant"]["hgvsp"], str)
assert detail["score"] > 0
assert [t["hpo_id"] for t in detail["matched_terms"]] == [h for h, _ in NF2_TERMS]
assert "annotations" in detail
@pytest.mark.usefixtures("db")
async def test_the_report_is_the_decision_trail(client: AsyncClient) -> None:
case_id, _ = await a_case_with_candidates()
items = (await client.get(f"/api/cases/{case_id}/candidates")).json()["items"]
await client.post(f"/api/variants/{items[0]['variant']['id']}/decision",
json={"state": "shortlisted", "reason": "fits the phenotype"})
await client.post(f"/api/variants/{items[1]['variant']['id']}/decision",
json={"state": "dismissed", "reason": "gene unrelated to phenotype"})
report = (await client.get(f"/api/cases/{case_id}/report")).json()
assert report["funnel"]["candidates"] == 2
assert [v["variant"]["gene"] for v in report["shortlisted"]] == ["NF2"]
assert report["shortlisted"][0]["decision"]["reason"] == "fits the phenotype"
assert [v["variant"]["gene"] for v in report["dismissed"]] == ["CHEK2"]
assert report["provenance"]["vep_version"] == "113.0"
@pytest.mark.usefixtures("db")
async def test_candidates_can_still_be_filtered(client: AsyncClient) -> None:
case_id, _ = await a_case_with_candidates()
page = (await client.get(f"/api/cases/{case_id}/candidates", params={"gene": "chek2"})).json()
assert [c["variant"]["gene"] for c in page["items"]] == ["CHEK2"]
# The funnel still describes the whole case, not the filtered view.
assert page["funnel"]["total"] == 4
@pytest.mark.usefixtures("db")
async def test_phenotype_search_backs_the_picker(client: AsyncClient) -> None:
await seed_case(gene_terms={"NF2": NF2_TERMS}, variants=[])
found = (await client.get("/api/phenotypes", params={"q": "vestibular"})).json()
assert found == [{"hpo_id": "HP:0009592", "label": "Vestibular schwannoma"}]
@pytest.mark.usefixtures("db")
async def test_candidates_before_the_pipeline_has_run(client: AsyncClient) -> None:
r = await client.post("/api/cases", json={"name": "empty", "vcf_uri": "gs://bucket/x.vcf.gz"})
page = (await client.get(f"/api/cases/{r.json()['id']}/candidates")).json()
assert page["items"] == []
assert page["funnel"]["total"] == 0
+8 -8
View File
@@ -26,9 +26,9 @@ class FakeJobsClient:
return self.result
async def new_sample(client: AsyncClient) -> str:
async def new_case(client: AsyncClient) -> str:
r = await client.post(
"/api/samples",
"/api/cases",
json={"name": f"s-{uuid.uuid4()}", "vcf_uri": "gs://bucket/x.vcf.gz", "assembly": "GRCh37"},
)
assert r.status_code == 201, r.text
@@ -49,9 +49,9 @@ async def test_annotate_executes_the_job_with_pipeline_arguments(
) -> None:
jobs = FakeJobsClient(FakeOperation("projects/p/locations/l/executions/rarelens-nextflow-abc12"))
monkeypatch.setattr(events, "_jobs_client", lambda: jobs)
sample_id = await new_sample(client)
case_id = await new_case(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
r = await client.post(f"/api/cases/{case_id}/annotate")
assert r.status_code == 202
job = r.json()
assert job["status"] == "running"
@@ -74,9 +74,9 @@ async def test_a_failed_execution_call_marks_the_job_failed(
monkeypatch.setattr(
events, "_jobs_client", lambda: FakeJobsClient(RuntimeError("403 permission denied"))
)
sample_id = await new_sample(client)
case_id = await new_case(client)
job = (await client.post(f"/api/samples/{sample_id}/annotate")).json()
job = (await client.post(f"/api/cases/{case_id}/annotate")).json()
assert job["status"] == "failed"
assert "403 permission denied" in job["log"]
@@ -91,7 +91,7 @@ async def test_cloud_run_job_takes_precedence_over_pubsub(
monkeypatch.setattr(
events, "_publisher", lambda: pytest.fail("Pub/Sub must not be used in the serverless track")
)
sample_id = await new_sample(client)
case_id = await new_case(client)
assert (await client.post(f"/api/samples/{sample_id}/annotate")).json()["status"] == "running"
assert (await client.post(f"/api/cases/{case_id}/annotate")).json()["status"] == "running"
assert len(jobs.requests) == 1
+2 -2
View File
@@ -5,8 +5,8 @@ from httpx import AsyncClient
@pytest.mark.usefixtures("db")
async def test_resources_live_under_api_prefix(client: AsyncClient) -> None:
# The ingress forwards /api/* unchanged, so the app itself must serve that prefix.
assert (await client.get("/api/samples")).status_code == 200
assert (await client.get("/samples")).status_code == 404
assert (await client.get("/api/cases")).status_code == 200
assert (await client.get("/cases")).status_code == 404
async def test_health_stays_at_root_for_probes(client: AsyncClient) -> None:
-18
View File
@@ -1,18 +0,0 @@
import pytest
from httpx import AsyncClient
@pytest.mark.usefixtures("db")
async def test_duplicate_sample_name_is_409_not_500(client: AsyncClient) -> None:
body = {"name": "HG002", "vcf_uri": "gs://bucket/hg002.vcf.gz"}
assert (await client.post("/api/samples", json=body)).status_code == 201
r = await client.post("/api/samples", json=body)
assert r.status_code == 409
assert "HG002" in r.json()["detail"]
async def test_unknown_assembly_is_rejected(client: AsyncClient) -> None:
r = await client.post(
"/api/samples", json={"name": "a", "vcf_uri": "gs://bucket/a.vcf.gz", "assembly": "hg19"}
)
assert r.status_code == 422
+32 -20
View File
@@ -4,11 +4,12 @@ import uuid
import numpy as np
import pandas as pd
import pytest
from factories import seed_case
from httpx import AsyncClient
from sqlalchemy import select
from app.db import SessionLocal
from app.models import Job, JobStatus, Prediction, Sample, Variant
from app.models import JobStatus, Prediction, Variant
from app.services import scoring
@@ -40,20 +41,16 @@ class FakeModel:
return np.full(len(frame), self.score)
async def make_job(status: JobStatus, n_variants: int) -> uuid.UUID:
async with SessionLocal() as s:
sample = Sample(name=f"s-{uuid.uuid4()}", vcf_uri="gs://b/x.vcf.gz", assembly="GRCh38")
job = Job(sample=sample, status=status)
s.add_all([sample, job, *(variant(job=job, pos=i + 1) for i in range(n_variants))])
await s.commit()
return job.id
async def make_case(status: JobStatus, n_variants: int) -> tuple[uuid.UUID, uuid.UUID]:
return await seed_case(
status=status,
variants=[{"pos": i + 1, "annotations": {}} for i in range(n_variants)],
)
async def predictions(job_id: uuid.UUID) -> list[Prediction]:
async with SessionLocal() as s:
rows = await s.scalars(
select(Prediction).join(Variant).where(Variant.job_id == job_id)
)
rows = await s.scalars(select(Prediction).join(Variant).where(Variant.job_id == job_id))
return list(rows)
@@ -61,15 +58,15 @@ async def predictions(job_id: uuid.UUID) -> list[Prediction]:
async def test_scoring_twice_updates_instead_of_failing(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
job_id = await make_job(JobStatus.succeeded, n_variants=3)
case_id, job_id = await make_case(JobStatus.succeeded, n_variants=3)
monkeypatch.setattr(scoring, "load_model", lambda: (FakeModel(0.9), "7"))
r = await client.post(f"/api/predictions/score/{job_id}")
r = await client.post(f"/api/cases/{case_id}/score")
assert r.status_code == 200, r.text
assert r.json() == {"job_id": str(job_id), "scored": 3, "model_version": "7"}
assert r.json() == {"case_id": str(case_id), "scored": 3, "model_version": "7"}
monkeypatch.setattr(scoring, "load_model", lambda: (FakeModel(0.2), "8"))
r = await client.post(f"/api/predictions/score/{job_id}")
r = await client.post(f"/api/cases/{case_id}/score")
assert r.status_code == 200, r.text
preds = await predictions(job_id)
@@ -78,13 +75,28 @@ async def test_scoring_twice_updates_instead_of_failing(
@pytest.mark.usefixtures("db")
async def test_scoring_unknown_job_is_404(client: AsyncClient) -> None:
r = await client.post(f"/api/predictions/score/{uuid.uuid4()}")
async def test_scoring_an_unknown_case_is_404(client: AsyncClient) -> None:
r = await client.post(f"/api/cases/{uuid.uuid4()}/score")
assert r.status_code == 404
@pytest.mark.usefixtures("db")
async def test_scoring_unfinished_job_is_409(client: AsyncClient) -> None:
job_id = await make_job(JobStatus.running, n_variants=1)
r = await client.post(f"/api/predictions/score/{job_id}")
async def test_scoring_before_the_annotation_finishes_is_409(client: AsyncClient) -> None:
case_id, _ = await make_case(JobStatus.running, n_variants=1)
r = await client.post(f"/api/cases/{case_id}/score")
assert r.status_code == 409
@pytest.mark.usefixtures("db")
async def test_an_unreachable_model_registry_is_explained_not_a_500(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
case_id, _ = await make_case(JobStatus.succeeded, n_variants=1)
def unreachable() -> tuple:
raise ConnectionError("connection refused to http://localhost:5000")
monkeypatch.setattr(scoring, "load_model", unreachable)
r = await client.post(f"/api/cases/{case_id}/score")
assert r.status_code == 503
assert "connection refused" in r.json()["detail"]
+4 -4
View File
@@ -2,7 +2,7 @@ import pytest
from httpx import AsyncClient
from pydantic import ValidationError
from app.schemas import SampleCreate
from app.schemas import CaseCreate
@pytest.mark.parametrize(
@@ -16,7 +16,7 @@ from app.schemas import SampleCreate
],
)
def test_vcf_uri_accepts_gcs_objects_and_files_under_the_data_root(uri: str) -> None:
assert SampleCreate(name="s", vcf_uri=uri).vcf_uri == uri
assert CaseCreate(name="s", vcf_uri=uri).vcf_uri == uri
@pytest.mark.parametrize(
@@ -36,11 +36,11 @@ def test_vcf_uri_accepts_gcs_objects_and_files_under_the_data_root(uri: str) ->
)
def test_vcf_uri_rejects_everything_else(uri: str) -> None:
with pytest.raises(ValidationError):
SampleCreate(name="s", vcf_uri=uri)
CaseCreate(name="s", vcf_uri=uri)
async def test_bad_vcf_uri_is_422_at_the_api(client: AsyncClient) -> None:
r = await client.post("/api/samples", json={"name": "s", "vcf_uri": "/etc/passwd"})
r = await client.post("/api/cases", json={"name": "s", "vcf_uri": "/etc/passwd"})
assert r.status_code == 422
+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]
-46
View File
@@ -1,46 +0,0 @@
import uuid
import pytest
from factories import seed_job
from httpx import AsyncClient
@pytest.mark.parametrize(
"paging", [{"limit": 0}, {"limit": -1}, {"limit": 501}, {"offset": -1}]
)
async def test_out_of_range_paging_is_422_not_500(client: AsyncClient, paging: dict) -> None:
r = await client.get("/api/variants", params={"job_id": str(uuid.uuid4()), **paging})
assert r.status_code == 422
async def positions(client: AsyncClient, job_id: uuid.UUID) -> list[tuple[str, int, str]]:
r = await client.get("/api/variants", params={"job_id": str(job_id)})
assert r.status_code == 200, r.text
return [(v["chrom"], v["pos"], v["alt"]) for v in r.json()["items"]]
@pytest.mark.usefixtures("db")
async def test_chromosomes_sort_naturally(client: AsyncClient) -> None:
job_id = await seed_job([{"chrom": c} for c in ["10", "MT", "2", "X", "chr3", "1", "Y"]])
assert [c for c, _, _ in await positions(client, job_id)] == [
"1", "2", "chr3", "10", "X", "Y", "MT",
]
@pytest.mark.usefixtures("db")
async def test_same_position_keeps_insertion_order_across_pages(client: AsyncClient) -> None:
# Split multiallelics share chrom/pos; without a tiebreak, pages can repeat or skip rows.
job_id = await seed_job([{"pos": 5, "alt": a} for a in "CGT"])
assert [a for _, _, a in await positions(client, job_id)] == ["C", "G", "T"]
@pytest.mark.usefixtures("db")
async def test_long_vep_strings_are_stored(client: AsyncClient) -> None:
clin_sig = ",".join(["conflicting_classifications_of_pathogenicity"] * 8)
consequence = (
"splice_region_variant&splice_polypyrimidine_tract_variant&intron_variant"
"&NMD_transcript_variant&non_coding_transcript_variant"
)
job_id = await seed_job([{"clinvar_sig": clin_sig, "consequence": consequence}])
[v] = (await client.get("/api/variants", params={"job_id": str(job_id)})).json()["items"]
assert (v["clinvar_sig"], v["consequence"]) == (clin_sig, consequence)