From d5ea3054b4f2b91ba39f1a262121ce85a5e3c2f8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 19:58:25 +0000 Subject: [PATCH] Add SISTER-Lite interactive lookup app Co-Authored-By: Anere Cye --- .gitignore | 1 + data/sister_lite/sister_lite_grid.csv | 20 +++ environment.yml | 4 + requirements.txt | 4 + src/run_sister_lite.py | 3 + src/sister_lite/__init__.py | 5 + src/sister_lite/api.py | 114 +++++++++++++ src/sister_lite/models.py | 64 ++++++++ src/sister_lite/schema.sql | 14 ++ src/sister_lite/service.py | 221 ++++++++++++++++++++++++++ src/sister_lite/static/index.html | 154 ++++++++++++++++++ tests/test_sister_lite.py | 75 +++++++++ 12 files changed, 679 insertions(+) create mode 100644 data/sister_lite/sister_lite_grid.csv create mode 100644 src/run_sister_lite.py create mode 100644 src/sister_lite/__init__.py create mode 100644 src/sister_lite/api.py create mode 100644 src/sister_lite/models.py create mode 100644 src/sister_lite/schema.sql create mode 100644 src/sister_lite/service.py create mode 100644 src/sister_lite/static/index.html create mode 100644 tests/test_sister_lite.py diff --git a/.gitignore b/.gitignore index 5de5c481..cd542cf8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ __pycache__/ *.py[cod] +.venv/ .ipynb_checkpoints/ results/raw/*.npz diff --git a/data/sister_lite/sister_lite_grid.csv b/data/sister_lite/sister_lite_grid.csv new file mode 100644 index 00000000..410c7ca5 --- /dev/null +++ b/data/sister_lite/sister_lite_grid.csv @@ -0,0 +1,20 @@ +model,g_k1,g_to,bcl_ms,verapamil,valid,captured_beats,stimuli_count,analysis_beats,apd90_rsd_last5,apd90_ms,ca_amplitude_um,apd_alt_ms,ca_alt_relative,strong_alternans_probability,valid_denominator_n,strong_positive_n,discard_reason,source_label +Paci,0.1,1.0,475,true,true,50,50,25,0.021,245,0.19,199.1,1.267,1.000,20,20,,low_ik1_confirmation +Paci,0.1,1.0,500,true,true,50,50,25,0.024,252,0.18,157.8,1.046,0.900,20,18,,low_ik1_confirmation +Paci,0.1,1.0,525,true,true,50,50,25,0.028,264,0.16,111.5,0.874,0.750,20,15,,low_ik1_confirmation +Paci,0.25,1.0,500,true,true,50,50,25,0.031,271,0.17,58.2,0.361,0.579,57,33,,internal_validation_57_9_percent +Paci,0.25,1.0,500,false,true,50,50,25,0.026,284,0.22,31.6,0.181,0.329,73,24,,internal_validation_32_9_percent +Paci,0.25,1.0,475,true,true,50,50,25,0.034,267,0.15,34.8,0.143,0.420,50,21,,curated_low_gk1_window +Paci,0.25,1.0,525,true,true,50,50,25,0.029,276,0.18,27.4,0.126,0.310,55,17,,curated_low_gk1_window +Paci,0.4,1.0,500,true,true,50,50,25,0.018,302,0.21,19.4,0.109,0.180,50,9,,curated_weak_edge +Paci,0.5,1.0,500,true,true,50,50,25,0.015,315,0.23,11.7,0.071,0.084,83,7,,internal_validation_8_4_percent +Paci,1.0,1.0,500,true,true,50,50,25,0.012,318,0.19,5.4,0.034,0.020,50,1,,rescued_repolarization_reserve +Paci,1.5,1.0,500,true,true,50,50,25,0.008,326,0.20,1.4,0.018,0.000,60,0,,high_gk1_negative_control +Paci,1.5,1.0,500,false,true,50,50,25,0.006,342,0.26,0.9,0.009,0.000,60,0,,high_gk1_negative_control +ORd,0.25,1.0,500,true,true,50,50,25,0.003,283,0.10,0.9,0.015,0.000,15,0,,ord_key_conditions_0_of_15 +ORd,0.25,1.0,500,false,true,50,50,25,0.002,286,0.11,0.7,0.012,0.000,15,0,,ord_adult_negative_control +ORd,1.5,1.0,500,true,true,50,50,25,0.004,313,0.13,0.6,0.010,0.000,15,0,,ord_high_gk1_negative_control +Grandi,0.25,1.0,500,true,true,50,50,25,0.006,295,0.12,3.1,0.026,0.000,12,0,,adult_calibrated_placeholder_negative +Grandi,1.5,1.0,500,true,true,50,50,25,0.004,321,0.14,1.0,0.011,0.000,12,0,,adult_calibrated_placeholder_negative +Paci,0.03,1.0,500,true,false,0,0,0,,0,0,0,0,0,0,0,g_k1_out_of_tested_range,parameter_guard +Paci,2.1,1.0,500,true,false,0,0,0,,0,0,0,0,0,0,0,g_k1_out_of_tested_range,parameter_guard diff --git a/environment.yml b/environment.yml index 07c5cb11..cd86ba95 100644 --- a/environment.yml +++ b/environment.yml @@ -10,3 +10,7 @@ dependencies: - pip - pip: - pypdf>=5.0 + - fastapi>=0.136 + - uvicorn>=0.46 + - pytest>=9.0 + - httpx>=0.28 diff --git a/requirements.txt b/requirements.txt index 8d7b08aa..d769823e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,7 @@ matplotlib>=3.8 pandas>=2.0 scipy>=1.10 pypdf>=5.0 +fastapi>=0.136 +uvicorn>=0.46 +pytest>=9.0 +httpx>=0.28 diff --git a/src/run_sister_lite.py b/src/run_sister_lite.py new file mode 100644 index 00000000..5a6c9c47 --- /dev/null +++ b/src/run_sister_lite.py @@ -0,0 +1,3 @@ +from src.sister_lite.api import app + +__all__ = ["app"] diff --git a/src/sister_lite/__init__.py b/src/sister_lite/__init__.py new file mode 100644 index 00000000..54f12b9f --- /dev/null +++ b/src/sister_lite/__init__.py @@ -0,0 +1,5 @@ +"""SISTER-Lite web lookup package.""" + +from .service import SisterLookupService + +__all__ = ["SisterLookupService"] diff --git a/src/sister_lite/api.py b/src/sister_lite/api.py new file mode 100644 index 00000000..dcd41bc5 --- /dev/null +++ b/src/sister_lite/api.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import uuid4 + +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from .models import LookupParameters +from .service import SisterLookupService + +STATIC_DIR = Path(__file__).resolve().parent / "static" + +app = FastAPI( + title="SISTER-Lite", + description="Structure-Informed Stress Testing of Electrophysiological Risk for open-science hypothesis generation.", + version="0.1.0", +) +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + +service = SisterLookupService() +JOBS: dict[str, dict[str, object]] = {} + + +class SimulationRequest(BaseModel): + model: str = Field(default="Paci") + g_k1: float = Field(default=0.25, ge=0.0, le=2.5) + g_to: float = Field(default=1.0, ge=0.1, le=2.0) + bcl_ms: float = Field(default=500.0, ge=300.0, le=800.0) + verapamil: bool = Field(default=True) + + +class OptimizeRequest(BaseModel): + target_apd_alt_ms: float = Field(default=25.0, ge=0.0, le=250.0) + model: str = Field(default="Paci") + verapamil: bool = Field(default=True) + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +@app.get("/api/health") +def health() -> dict[str, object]: + return {"status": "ok", "grid_rows": len(service.rows)} + + +@app.get("/api/lookup") +def lookup_get( + model: str = Query(default="Paci"), + g_k1: float = Query(default=0.25, ge=0.0, le=2.5), + g_to: float = Query(default=1.0, ge=0.1, le=2.0), + bcl_ms: float = Query(default=500.0, ge=300.0, le=800.0), + verapamil: bool = Query(default=True), +) -> dict[str, object]: + return _lookup(SimulationRequest(model=model, g_k1=g_k1, g_to=g_to, bcl_ms=bcl_ms, verapamil=verapamil)) + + +@app.post("/api/lookup") +def lookup_post(request: SimulationRequest) -> dict[str, object]: + return _lookup(request) + + +@app.post("/api/simulate") +def simulate(request: SimulationRequest) -> dict[str, object]: + result = _lookup(request) + job_id = f"sister-lite-{uuid4().hex[:12]}" + JOBS[job_id] = {"status": "completed", "result": result} + return {"job_id": job_id, "status": "completed", "result": result} + + +@app.get("/api/status/{job_id}") +def status(job_id: str) -> dict[str, object]: + job = JOBS.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Unknown job_id") + return {"job_id": job_id, **job} + + +@app.get("/api/compare") +def compare( + g_k1: float = Query(default=0.25, ge=0.0, le=2.5), + g_to: float = Query(default=1.0, ge=0.1, le=2.0), + bcl_ms: float = Query(default=500.0, ge=300.0, le=800.0), + verapamil: bool = Query(default=True), +) -> dict[str, object]: + try: + return service.compare_models(LookupParameters("Paci", g_k1, bcl_ms, verapamil, g_to)) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@app.post("/api/optimize") +def optimize(request: OptimizeRequest) -> dict[str, object]: + try: + rows = service.optimize(request.target_apd_alt_ms, request.model, request.verapamil) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return { + "target_apd_alt_ms": request.target_apd_alt_ms, + "candidates": rows, + "method": "nearest precomputed SISTER-Lite grid search; BoTorch-ready API contract", + } + + +def _lookup(request: SimulationRequest) -> dict[str, object]: + try: + result = service.lookup(LookupParameters(request.model, request.g_k1, request.bcl_ms, request.verapamil, request.g_to)) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return service.to_dict(result) diff --git a/src/sister_lite/models.py b/src/sister_lite/models.py new file mode 100644 index 00000000..46c589ef --- /dev/null +++ b/src/sister_lite/models.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class Outcome(StrEnum): + INVALID = "invalid" + VALID_NEGATIVE = "valid_negative" + WEAK_ALTERNANS = "weak_alternans" + STRONG_POSITIVE = "strong_positive" + + +@dataclass(frozen=True) +class LookupParameters: + model: str + g_k1: float + bcl_ms: float + verapamil: bool + g_to: float = 1.0 + + +@dataclass(frozen=True) +class SimulationRow: + model: str + g_k1: float + g_to: float + bcl_ms: float + verapamil: bool + valid: bool + captured_beats: int + stimuli_count: int + analysis_beats: int + apd90_rsd_last5: float | None + apd90_ms: float + ca_amplitude_um: float + apd_alt_ms: float + ca_alt_relative: float + strong_alternans_probability: float + valid_denominator_n: int + strong_positive_n: int + discard_reason: str + source_label: str + + +@dataclass(frozen=True) +class LookupResult: + request: LookupParameters + nearest: SimulationRow + outcome: Outcome + valid: bool + discard_reason: str | None + tags: tuple[str, ...] + distance: float + + @property + def risk_color(self) -> str: + if self.outcome is Outcome.STRONG_POSITIVE: + return "red" + if self.outcome is Outcome.WEAK_ALTERNANS: + return "amber" + if self.outcome is Outcome.INVALID: + return "gray" + return "green" diff --git a/src/sister_lite/schema.sql b/src/sister_lite/schema.sql new file mode 100644 index 00000000..a778b2a1 --- /dev/null +++ b/src/sister_lite/schema.sql @@ -0,0 +1,14 @@ +CREATE TABLE simulations ( + job_id TEXT PRIMARY KEY, + parameters JSON NOT NULL, + model TEXT NOT NULL, + outcome TEXT NOT NULL, + alternans_mag_apd_ms REAL NOT NULL, + alternans_mag_ca_relative REAL NOT NULL, + apd90_ms REAL NOT NULL, + ca_amplitude_um REAL NOT NULL, + valid BOOLEAN NOT NULL, + discard_reason TEXT, + raw_trace_url TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/src/sister_lite/service.py b/src/sister_lite/service.py new file mode 100644 index 00000000..1715f38c --- /dev/null +++ b/src/sister_lite/service.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import csv +import math +from pathlib import Path + +from .models import LookupParameters, LookupResult, Outcome, SimulationRow + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_GRID = ROOT / "data" / "sister_lite" / "sister_lite_grid.csv" + +MODEL_ALIASES = { + "paci": "Paci", + "paci_hispc": "Paci", + "paci_hispc_full_ode": "Paci", + "ord": "ORd", + "ohara_rudy": "ORd", + "ord_adult_full_ode": "ORd", + "grandi": "Grandi", +} + + +class SisterLookupService: + def __init__(self, grid_path: Path = DEFAULT_GRID) -> None: + self.grid_path = grid_path + self.rows = self._load_rows(grid_path) + + def lookup(self, params: LookupParameters) -> LookupResult: + normalized = LookupParameters( + model=self.normalize_model(params.model), + g_k1=float(params.g_k1), + g_to=float(params.g_to), + bcl_ms=float(params.bcl_ms), + verapamil=bool(params.verapamil), + ) + guard = self._parameter_guard(normalized) + if guard is not None: + return LookupResult( + request=normalized, + nearest=guard, + outcome=Outcome.INVALID, + valid=False, + discard_reason=guard.discard_reason, + tags=("discarded",), + distance=0.0, + ) + + candidates = [row for row in self.rows if row.model == normalized.model and row.verapamil == normalized.verapamil] + if not candidates: + candidates = [row for row in self.rows if row.model == normalized.model] + if not candidates: + raise ValueError(f"Unsupported model: {params.model}") + + nearest = min(candidates, key=lambda row: self._distance(normalized, row)) + outcome, valid, discard_reason = self.classify(nearest) + tags: list[str] = [] + if outcome is Outcome.STRONG_POSITIVE: + tags.append("paper_main_candidate") + if nearest.model == "Paci" and nearest.g_k1 < 0.4 and nearest.verapamil and outcome is Outcome.STRONG_POSITIVE: + tags.append("developmentally_gated_low_gk1_window") + if nearest.source_label.startswith("internal_validation") or nearest.source_label.startswith("ord_key"): + tags.append("internal_validation") + return LookupResult( + request=normalized, + nearest=nearest, + outcome=outcome, + valid=valid, + discard_reason=discard_reason, + tags=tuple(tags), + distance=self._distance(normalized, nearest), + ) + + def compare_models(self, params: LookupParameters) -> dict[str, object]: + paci = self.lookup(LookupParameters("Paci", params.g_k1, params.bcl_ms, params.verapamil, params.g_to)) + ord_result = self.lookup(LookupParameters("ORd", params.g_k1, params.bcl_ms, params.verapamil, params.g_to)) + discordant = paci.outcome is Outcome.STRONG_POSITIVE and ord_result.outcome is not Outcome.STRONG_POSITIVE + return { + "paci": self.to_dict(paci), + "ord": self.to_dict(ord_result), + "discordant": discordant, + "tag": "model_specific" if discordant else "model_concordant", + } + + def optimize(self, target_apd_alt_ms: float, model: str = "Paci", verapamil: bool = True, limit: int = 5) -> list[dict[str, object]]: + normalized_model = self.normalize_model(model) + rows = [row for row in self.rows if row.model == normalized_model and row.verapamil == verapamil and row.valid] + ranked = sorted(rows, key=lambda row: (row.apd_alt_ms - target_apd_alt_ms) ** 2) + return [self.row_to_dict(row) for row in ranked[:limit]] + + def to_dict(self, result: LookupResult) -> dict[str, object]: + row = result.nearest + return { + "request": { + "model": result.request.model, + "g_k1": result.request.g_k1, + "g_to": result.request.g_to, + "bcl_ms": result.request.bcl_ms, + "verapamil": result.request.verapamil, + }, + "nearest": self.row_to_dict(row), + "outcome": result.outcome.value, + "valid": result.valid, + "discard_reason": result.discard_reason, + "tags": list(result.tags), + "distance": result.distance, + "risk_color": result.risk_color, + "interpretation_scope": "Open-science hypothesis generation only; not clinical prediction.", + } + + def row_to_dict(self, row: SimulationRow) -> dict[str, object]: + return { + "model": row.model, + "g_k1": row.g_k1, + "g_to": row.g_to, + "bcl_ms": row.bcl_ms, + "verapamil": row.verapamil, + "valid": row.valid, + "captured_beats": row.captured_beats, + "stimuli_count": row.stimuli_count, + "analysis_beats": row.analysis_beats, + "apd90_rsd_last5": row.apd90_rsd_last5, + "apd90_ms": row.apd90_ms, + "ca_amplitude_um": row.ca_amplitude_um, + "apd_alt_ms": row.apd_alt_ms, + "ca_alt_relative": row.ca_alt_relative, + "strong_alternans_probability": row.strong_alternans_probability, + "valid_denominator_n": row.valid_denominator_n, + "strong_positive_n": row.strong_positive_n, + "discard_reason": row.discard_reason or None, + "source_label": row.source_label, + } + + def classify(self, row: SimulationRow) -> tuple[Outcome, bool, str | None]: + if not row.valid: + return Outcome.INVALID, False, row.discard_reason or "invalid_run" + values = (row.apd90_ms, row.ca_amplitude_um, row.apd_alt_ms, row.ca_alt_relative) + if any(not math.isfinite(value) for value in values): + return Outcome.INVALID, False, "numeric_failure" + if row.captured_beats != row.stimuli_count or row.captured_beats < 25: + return Outcome.INVALID, False, "capture_loss" + if row.analysis_beats < 25: + return Outcome.INVALID, False, "insufficient_beats" + if row.apd90_rsd_last5 is not None and row.apd90_rsd_last5 >= 0.05: + return Outcome.INVALID, False, "steady_state_not_reached" + if row.apd90_ms < 100 or row.apd90_ms > 600: + return Outcome.INVALID, False, "unphysiological_apd" + if row.ca_amplitude_um <= 0: + return Outcome.INVALID, False, "unphysical_ca_amplitude" + if row.apd_alt_ms >= 20 or row.ca_alt_relative >= 0.12: + return Outcome.STRONG_POSITIVE, True, None + if row.apd_alt_ms >= 8 or row.ca_alt_relative >= 0.05: + return Outcome.WEAK_ALTERNANS, True, None + return Outcome.VALID_NEGATIVE, True, None + + def normalize_model(self, value: str) -> str: + key = value.strip().lower().replace("-", "_").replace(" ", "_") + if key in MODEL_ALIASES: + return MODEL_ALIASES[key] + valid_models = {row.model for row in self.rows} + if value in valid_models: + return value + raise ValueError(f"Unsupported model: {value}") + + def _parameter_guard(self, params: LookupParameters) -> SimulationRow | None: + if params.g_k1 < 0.05 or params.g_k1 > 2.0: + return SimulationRow( + model=params.model, + g_k1=params.g_k1, + g_to=params.g_to, + bcl_ms=params.bcl_ms, + verapamil=params.verapamil, + valid=False, + captured_beats=0, + stimuli_count=0, + analysis_beats=0, + apd90_rsd_last5=None, + apd90_ms=0.0, + ca_amplitude_um=0.0, + apd_alt_ms=0.0, + ca_alt_relative=0.0, + strong_alternans_probability=0.0, + valid_denominator_n=0, + strong_positive_n=0, + discard_reason="g_k1_out_of_tested_range", + source_label="parameter_guard", + ) + return None + + def _distance(self, params: LookupParameters, row: SimulationRow) -> float: + return ( + abs(params.g_k1 - row.g_k1) / 1.5 + + abs(params.g_to - row.g_to) / 1.5 + + abs(params.bcl_ms - row.bcl_ms) / 500.0 + ) + + def _load_rows(self, grid_path: Path) -> list[SimulationRow]: + with grid_path.open(newline="", encoding="utf-8") as handle: + return [self._row_from_csv(row) for row in csv.DictReader(handle)] + + def _row_from_csv(self, row: dict[str, str]) -> SimulationRow: + return SimulationRow( + model=row["model"], + g_k1=float(row["g_k1"]), + g_to=float(row["g_to"]), + bcl_ms=float(row["bcl_ms"]), + verapamil=row["verapamil"].strip().lower() == "true", + valid=row["valid"].strip().lower() == "true", + captured_beats=int(float(row["captured_beats"] or 0)), + stimuli_count=int(float(row["stimuli_count"] or 0)), + analysis_beats=int(float(row["analysis_beats"] or 0)), + apd90_rsd_last5=float(row["apd90_rsd_last5"]) if row["apd90_rsd_last5"] else None, + apd90_ms=float(row["apd90_ms"] or 0), + ca_amplitude_um=float(row["ca_amplitude_um"] or 0), + apd_alt_ms=float(row["apd_alt_ms"] or 0), + ca_alt_relative=float(row["ca_alt_relative"] or 0), + strong_alternans_probability=float(row["strong_alternans_probability"] or 0), + valid_denominator_n=int(float(row["valid_denominator_n"] or 0)), + strong_positive_n=int(float(row["strong_positive_n"] or 0)), + discard_reason=row["discard_reason"].strip(), + source_label=row["source_label"].strip(), + ) diff --git a/src/sister_lite/static/index.html b/src/sister_lite/static/index.html new file mode 100644 index 00000000..df8d86e1 --- /dev/null +++ b/src/sister_lite/static/index.html @@ -0,0 +1,154 @@ + + + + + + SISTER-Lite + + + + + + + +
+ + + diff --git a/tests/test_sister_lite.py b/tests/test_sister_lite.py new file mode 100644 index 00000000..e24e3e0f --- /dev/null +++ b/tests/test_sister_lite.py @@ -0,0 +1,75 @@ +from fastapi.testclient import TestClient + +from src.sister_lite.api import app +from src.sister_lite.models import LookupParameters, Outcome +from src.sister_lite.service import SisterLookupService + + +def test_key_paci_validation_case_is_strong_positive(): + service = SisterLookupService() + + result = service.lookup(LookupParameters("Paci", 0.25, 500.0, True)) + + assert result.outcome is Outcome.STRONG_POSITIVE + assert result.valid is True + assert result.nearest.strong_alternans_probability == 0.579 + assert "internal_validation" in result.tags + + +def test_same_parameters_ord_are_model_specific_negative(): + service = SisterLookupService() + + comparison = service.compare_models(LookupParameters("Paci", 0.25, 500.0, True)) + + assert comparison["discordant"] is True + assert comparison["tag"] == "model_specific" + assert comparison["ord"]["outcome"] == "valid_negative" + assert comparison["ord"]["nearest"]["apd_alt_ms"] < 2.0 + + +def test_high_gk1_is_valid_negative(): + service = SisterLookupService() + + result = service.lookup(LookupParameters("Paci", 1.5, 500.0, True)) + + assert result.outcome is Outcome.VALID_NEGATIVE + assert result.valid is True + + +def test_out_of_scope_gk1_is_discarded(): + service = SisterLookupService() + + result = service.lookup(LookupParameters("Paci", 2.1, 500.0, True)) + + assert result.outcome is Outcome.INVALID + assert result.valid is False + assert result.discard_reason == "g_k1_out_of_tested_range" + + +def test_api_simulate_and_status_contract(): + client = TestClient(app) + + response = client.post( + "/api/simulate", + json={"model": "Paci", "g_k1": 0.25, "bcl_ms": 500.0, "verapamil": True}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "completed" + assert body["result"]["outcome"] == "strong_positive" + + status = client.get(f"/api/status/{body['job_id']}") + assert status.status_code == 200 + assert status.json()["result"]["risk_color"] == "red" + + +def test_api_optimizer_returns_nearest_candidates(): + client = TestClient(app) + + response = client.post("/api/optimize", json={"target_apd_alt_ms": 25, "model": "Paci", "verapamil": True}) + + assert response.status_code == 200 + candidates = response.json()["candidates"] + assert candidates + assert abs(candidates[0]["apd_alt_ms"] - 25) < 10