mekosotto Claude Opus 4.7 (1M context) commited on
Commit
327b23d
·
1 Parent(s): 07b00eb

feat(researcher): DCE-MRI BBB permeability bridge + drug-dose adjuster

Browse files

Adds the only legitimate BBB <-> MRI bridge in the platform — researcher
lane only. Fusion engine still excludes BBB as a diagnostic modality.

Modules:
- src/models/bbb_permeability_map.py: scalar BBB leakage score in [0,1]
with two modes. heuristic_proxy (default) reuses the 2D resnet18
Alzheimer classifier and computes 1 - P(NonDemented). dce_onnx loads
a real 4D DCE-MRI ONNX model from BBB_PERMEABILITY_DCE_PATH and
averages a normalised Ktrans map; raises a clear FileNotFoundError
with contract documentation when no artifact exists.
- src/research/drug_dose_adjuster.py: pure-function dose revision keyed
on (BBB intact?, drug BBB-permeable?). Permeable drugs see an
aggressive linear cut floored at 30%; non-permeable drugs see a mild
cut floored at 60%; intact BBB returns baseline unchanged. Unknown
drug permeability is treated as permeable (safer). Rationale always
includes the 'research suggestion, not medical advice' disclaimer.

API:
- POST /predict/bbb_permeability_map (request: input_path + mode)
- POST /research/drug_dose_adjustment (request: smiles? + baseline_dose
+ bbb_score + drug_permeable?). When smiles is provided, the BBB
classifier auto-resolves drug_permeable, closing the researcher loop
end-to-end.
- New research_router mounted in src/api/main.py.

Agent tools (orchestrator-callable):
- compute_bbb_leakage_score (wraps the BBB permeability route)
- adjust_drug_dose (wraps the dose-adjustment route, BBB classifier
auto-resolves drug permeability when smiles is given)

Frontend:
- New 'Researcher' Streamlit tab with a 2-column flow: BBB leakage
scorer on the left, dose-adjuster on the right, with risk-level
emoji and rationale card.

Tests: 361 passed, 2 skipped (was 330+2; +31 tests, 0 regressions).
- tests/models/test_bbb_permeability_map.py: heuristic_proxy + DCE
contract; case-insensitive label matching; threshold semantics.
- tests/research/test_drug_dose_adjuster.py: all four (BBB x drug-perm)
quadrants; safety floors; monotonicity; rationale disclaimer; pydantic
validation edges.
- tests/api/test_bbb_research_routes.py: both new routes end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

README.md CHANGED
@@ -42,6 +42,7 @@ short_description: Living decision system for BBB, EEG, and MRI clinical ML
42
  | 9 | Agent/RAG hardening + MRI DL decision layer | Guarded orchestration + `POST /predict/mri` ONNX surface | Shipped — 242 passed, 2 skipped |
43
  | 10 | Multi-modal fusion engine | `POST /fusion/predict` + `run_fusion` agent tool — MRI + EEG + clinical scores → per-disease confidence with attribution | Shipped — 295 passed, 1 skipped |
44
  | 11 | External assets integration | 2D resnet18 MRI Alzheimer's path · TF-IDF clinical RAG with TR query expansion · stub-able EEG pretrained classifier | Shipped — 330 passed, 2 skipped |
 
45
 
46
  ### Fusion Engine
47
 
@@ -96,6 +97,49 @@ The Turkish keywords `alzheimer`, `parkinson`, `egzersiz`, `beslenme`,
96
  `tani`, `tedavi`, `risk`, `unutkanlik`, `titreme`, `demans` auto-expand
97
  to English equivalents so Turkish queries hit English chunks.
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  ### EEG Pretrained Classifier (stub-able for demo)
100
 
101
  `POST /predict/eeg` runs an sklearn-style classifier (any `predict_proba`
 
42
  | 9 | Agent/RAG hardening + MRI DL decision layer | Guarded orchestration + `POST /predict/mri` ONNX surface | Shipped — 242 passed, 2 skipped |
43
  | 10 | Multi-modal fusion engine | `POST /fusion/predict` + `run_fusion` agent tool — MRI + EEG + clinical scores → per-disease confidence with attribution | Shipped — 295 passed, 1 skipped |
44
  | 11 | External assets integration | 2D resnet18 MRI Alzheimer's path · TF-IDF clinical RAG with TR query expansion · stub-able EEG pretrained classifier | Shipped — 330 passed, 2 skipped |
45
+ | 12 | DCE-MRI BBB bridge + drug-dose adjuster | `POST /predict/bbb_permeability_map` (heuristic_proxy or dce_onnx) + `POST /research/drug_dose_adjustment` + Researcher Streamlit tab + `compute_bbb_leakage_score` & `adjust_drug_dose` agent tools | Shipped |
46
 
47
  ### Fusion Engine
48
 
 
97
  `tani`, `tedavi`, `risk`, `unutkanlik`, `titreme`, `demans` auto-expand
98
  to English equivalents so Turkish queries hit English chunks.
99
 
100
+ ### DCE-MRI BBB Bridge + Drug-Dose Adjuster (Researcher persona)
101
+
102
+ Clinical fact: Dynamic Contrast-Enhanced (DCE) MRI measures BBB leakage by
103
+ tracking gadolinium contrast washout. A leaky BBB lets drugs cross into
104
+ the brain at unsafe levels, so concentrations need revising.
105
+
106
+ This is the **only legitimate place where BBB and MRI couple** in the
107
+ platform — the Researcher lane only. The fusion engine's "BBB is NOT a
108
+ diagnostic modality" rule is preserved.
109
+
110
+ **`POST /predict/bbb_permeability_map`** — two modes:
111
+
112
+ - `heuristic_proxy` (default, demo-ready): reuses the 2D resnet18
113
+ Alzheimer's classifier; score = `1 - P(NonDemented)`. Anchored in the
114
+ published correlation between disease severity and BBB breakdown.
115
+ - `dce_onnx` (real DCE artifact, swap-in later): loads an ONNX model
116
+ trained on 4D DCE-MRI data, emits a Ktrans map normalised to `[0, 1]`.
117
+ Drop the artifact at `data/processed/bbb_permeability_dce.onnx` (or set
118
+ `BBB_PERMEABILITY_DCE_PATH`).
119
+
120
+ **`POST /research/drug_dose_adjustment`** — pure-function logic:
121
+
122
+ | BBB score | Drug BBB-permeable | Recommended dose |
123
+ |---|---|---|
124
+ | < 0.20 (intact) | any | 100% of baseline (low risk) |
125
+ | ≥ 0.20 (leaky) | yes | `max(30%, 1 − 0.7·score)` of baseline (moderate / high risk) |
126
+ | ≥ 0.20 (leaky) | no | `max(60%, 1 − 0.4·score)` of baseline (moderate risk) |
127
+ | ≥ 0.20 (leaky) | unknown | treated as permeable (safer assumption) |
128
+
129
+ When `smiles` is supplied, the BBB classifier auto-resolves the drug's
130
+ permeability — closes the researcher loop end-to-end. The rationale always
131
+ includes the sentence "Research suggestion, not medical advice."
132
+
133
+ Streamlit `Researcher` tab combines both into a single 2-column flow:
134
+ left side picks an MRI image and runs the leakage scorer; right side
135
+ takes a SMILES + baseline dose and computes a revised dose with risk
136
+ badge and rationale card.
137
+
138
+ Agent tools (orchestrator-callable):
139
+
140
+ - `compute_bbb_leakage_score` — wraps `/predict/bbb_permeability_map`.
141
+ - `adjust_drug_dose` — wraps `/research/drug_dose_adjustment`.
142
+
143
  ### EEG Pretrained Classifier (stub-able for demo)
144
 
145
  `POST /predict/eeg` runs an sklearn-style classifier (any `predict_proba`
docs/superpowers/plans/2026-05-02-bbb-mri-bridge-and-dose-adjuster.md ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BBB ↔ MRI Bridge + Drug-Dose Adjuster — Implementation Notes
2
+
3
+ > Implemented inline on `feat/external-assets-integration`. This document is the design record.
4
+
5
+ ## Why
6
+
7
+ Clinical fact: **DCE-MRI** (Dynamic Contrast-Enhanced MRI) measures BBB leakage by tracking gadolinium contrast washout — Ktrans, Kep, Ve maps. Compromised BBB lets molecules cross into the brain that normally wouldn't. Two consequences:
8
+
9
+ 1. A "BBB Permeability Map" or scalar score from a patient's MRI is the **primary data point for the researcher persona**.
10
+ 2. **If the BBB is leaky, drug concentrations need revising** — a CNS-permeable drug crosses too easily; even a non-CNS drug may now reach the brain at unsafe levels.
11
+
12
+ This plan adds both: the bridge (MRI → BBB permeability score) and the adjuster (drug dose revision based on the score).
13
+
14
+ ## Independence
15
+
16
+ This is the **only legitimate place BBB and MRI couple** in the platform — and it sits in the **researcher** lane, not the doctor's diagnostic lane. The fusion engine's "BBB is NOT a fusion modality" rule is preserved: this bridge does not feed into Alzheimer's/Parkinson's confidence.
17
+
18
+ ## Architecture (two modules + two routes + two agent tools)
19
+
20
+ ```
21
+ src/models/bbb_permeability_map.py pure-Python scorer with two modes
22
+ src/research/drug_dose_adjuster.py pure-function dose revision logic
23
+
24
+ POST /predict/bbb_permeability_map {input_path, mode} -> {score, interpretation, method}
25
+ POST /research/drug_dose_adjustment {smiles?, baseline_dose_mg, bbb_score, drug_permeable?}
26
+ -> {recommended_dose_mg, factor, risk_level, rationale}
27
+
28
+ agent tool: compute_bbb_leakage_score
29
+ agent tool: adjust_drug_dose
30
+ ```
31
+
32
+ ### Permeability scoring modes
33
+
34
+ - **`heuristic_proxy`** (default, demo-ready). Uses the existing 2D resnet18 4-class
35
+ classifier's class probabilities. Score = `1 - P(NonDemented)` — published
36
+ literature shows BBB breakdown correlates with disease severity. Auditable
37
+ and demonstrable today.
38
+ - **`dce_onnx`** (real-DCE artifact, swap-in later). Loads an ONNX model from
39
+ `data/processed/bbb_permeability_dce.onnx` (env: `BBB_PERMEABILITY_DCE_PATH`).
40
+ Contract: input 4D NIfTI `(X, Y, Z, T)`, output a 3D Ktrans map normalized
41
+ to `[0, 1]`. Stub for now; works when artifact lands.
42
+
43
+ ### Dose-adjustment math
44
+
45
+ `adjust(baseline_mg, perm_score, drug_permeable, safety_factor=0.5)`:
46
+
47
+ | BBB intact (score < 0.2) | any drug | factor=1.0, risk=low |
48
+ | BBB leaky, drug permeable | factor = max(0.3, 1 - 0.7·score), risk = high if score > 0.6 else moderate |
49
+ | BBB leaky, drug NOT permeable | factor = max(0.6, 1 - 0.4·score), risk = moderate |
50
+ | drug_permeable=None (unknown) | treat as permeable (safer assumption) |
51
+
52
+ The adjuster is a **pure function**. The route can optionally call `bbb_model.predict_one(smiles)` to populate `drug_permeable` from the existing BBB classifier — closing the researcher loop end-to-end.
53
+
54
+ ## Tests
55
+
56
+ - `tests/models/test_bbb_permeability_map.py` — heuristic-proxy correctness, mode dispatch, missing-artifact errors.
57
+ - `tests/research/test_drug_dose_adjuster.py` — all four (BBB × drug-permeability) quadrants, monotonicity, safety-floor invariants.
58
+ - `tests/api/test_bbb_research_routes.py` — both new routes end-to-end.
59
+
60
+ ## What this does NOT do
61
+
62
+ - Does not retrain anything.
63
+ - Does not compute real Ktrans/Kep/Ve from DCE data — that requires a 4D pharmacokinetic model we don't ship. The `dce_onnx` mode is a contract for an external trainer.
64
+ - Does not feed into the fusion engine. Researcher-only flow.
65
+ - Does not prescribe — the rationale string explicitly says "research suggestion, not medical advice".
66
+
67
+ ## Demo path
68
+
69
+ 1. Researcher pastes a SMILES (e.g., `CCO`) and a baseline dose (e.g., `200 mg`).
70
+ 2. Researcher uploads/picks an MRI image.
71
+ 3. Streamlit calls `/predict/mri` (resnet18_2d if active, else volumetric ONNX) → BBB classifier on SMILES → `/research/drug_dose_adjustment`.
72
+ 4. Card shows: BBB leakage gauge, drug BBB permeability badge, recommended dose with adjustment factor, plain-language rationale.
src/agents/schemas.py CHANGED
@@ -34,6 +34,40 @@ class MRIPipelineInput(BaseModel):
34
  )
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  class RetrieveContextInput(BaseModel):
38
  """Input for `retrieve_context` — natural-language query into the KB."""
39
  query: str = Field(..., min_length=2, description="Search query for the knowledge base")
 
34
  )
35
 
36
 
37
+ class BBBPermeabilityMapInput(BaseModel):
38
+ """Input for `compute_bbb_leakage_score` — MRI input + scoring mode."""
39
+ input_path: str = Field(..., description="Path to MRI input (2D image for heuristic_proxy; 4D NIfTI for dce_onnx).")
40
+ mode: Literal["heuristic_proxy", "dce_onnx"] = Field(
41
+ "heuristic_proxy",
42
+ description="'heuristic_proxy' (default) | 'dce_onnx' (real DCE artifact)",
43
+ )
44
+
45
+
46
+ class BBBPermeabilityMapOutput(BaseModel):
47
+ permeability_score: float
48
+ interpretation: str
49
+ method: str
50
+ voxel_map_available: bool
51
+
52
+
53
+ class DrugDoseAdjustmentInput(BaseModel):
54
+ """Input for `adjust_drug_dose` — baseline + patient + drug profile."""
55
+ baseline_dose_mg: float = Field(..., gt=0.0)
56
+ bbb_permeability_score: float = Field(..., ge=0.0, le=1.0)
57
+ drug_bbb_permeable: bool | None = None
58
+ smiles: str | None = Field(
59
+ None, description="Optional SMILES; auto-resolves drug_bbb_permeable when given.",
60
+ )
61
+
62
+
63
+ class DrugDoseAdjustmentOutput(BaseModel):
64
+ recommended_dose_mg: float
65
+ adjustment_factor: float
66
+ risk_level: str
67
+ rationale: str
68
+ drug_bbb_permeable: bool | None = None
69
+
70
+
71
  class RetrieveContextInput(BaseModel):
72
  """Input for `retrieve_context` — natural-language query into the KB."""
73
  query: str = Field(..., min_length=2, description="Search query for the knowledge base")
src/agents/tools.py CHANGED
@@ -1,7 +1,7 @@
1
  """Tool dataclass + registry. Wraps each pipeline + the RAG retriever as a
2
  function-callable tool the orchestrator can invoke.
3
 
4
- Public entry: `build_default_tools(rag_index_dir)` returns the 5 tools.
5
  """
6
  from __future__ import annotations
7
 
@@ -12,8 +12,12 @@ from typing import Any, Callable
12
  from pydantic import BaseModel, ValidationError
13
 
14
  from src.agents.schemas import (
 
 
15
  BBBPipelineInput,
16
  BBBPipelineOutput,
 
 
17
  EEGPipelineInput,
18
  EEGPipelineOutput,
19
  MRIPipelineInput,
@@ -203,6 +207,59 @@ def _make_retrieve_executor(
203
  return execute
204
 
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  def build_default_tools(
207
  rag_index_dir: Path | None,
208
  processed_dir: Path = Path("data/processed"),
@@ -272,4 +329,29 @@ def build_default_tools(
272
  output_model=FusionOutput,
273
  execute=lambda inp: fuse_engine(inp),
274
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  ]
 
1
  """Tool dataclass + registry. Wraps each pipeline + the RAG retriever as a
2
  function-callable tool the orchestrator can invoke.
3
 
4
+ Public entry: `build_default_tools(rag_index_dir)` returns the 7 tools.
5
  """
6
  from __future__ import annotations
7
 
 
12
  from pydantic import BaseModel, ValidationError
13
 
14
  from src.agents.schemas import (
15
+ BBBPermeabilityMapInput,
16
+ BBBPermeabilityMapOutput,
17
  BBBPipelineInput,
18
  BBBPipelineOutput,
19
+ DrugDoseAdjustmentInput,
20
+ DrugDoseAdjustmentOutput,
21
  EEGPipelineInput,
22
  EEGPipelineOutput,
23
  MRIPipelineInput,
 
207
  return execute
208
 
209
 
210
+ def _make_bbb_permeability_executor() -> Callable[[BBBPermeabilityMapInput], BBBPermeabilityMapOutput]:
211
+ def execute(inp: BBBPermeabilityMapInput) -> BBBPermeabilityMapOutput:
212
+ from src.models import bbb_permeability_map as bbb_perm
213
+ result = bbb_perm.compute_permeability(
214
+ input_path=Path(inp.input_path),
215
+ mode=inp.mode,
216
+ )
217
+ return BBBPermeabilityMapOutput(
218
+ permeability_score=float(result["permeability_score"]),
219
+ interpretation=str(result["interpretation"]),
220
+ method=str(result["method"]),
221
+ voxel_map_available=bool(result.get("voxel_map_available", False)),
222
+ )
223
+
224
+ return execute
225
+
226
+
227
+ def _make_dose_adjuster_executor() -> Callable[[DrugDoseAdjustmentInput], DrugDoseAdjustmentOutput]:
228
+ def execute(inp: DrugDoseAdjustmentInput) -> DrugDoseAdjustmentOutput:
229
+ from src.research import drug_dose_adjuster
230
+
231
+ drug_permeable = inp.drug_bbb_permeable
232
+ if inp.smiles:
233
+ try:
234
+ from src.models import bbb_model
235
+ import os as _os
236
+ artifact = Path(_os.environ.get("BBB_MODEL_PATH", "data/processed/bbb_model.pkl"))
237
+ if artifact.exists():
238
+ model = bbb_model.load_model(artifact)
239
+ pred = bbb_model.predict_one(model, inp.smiles)
240
+ drug_permeable = bool(pred["label"] == 1)
241
+ except (FileNotFoundError, ValueError, KeyError) as e:
242
+ logger.warning(
243
+ "agent dose-adjuster could not auto-resolve BBB for smiles=%s: %s",
244
+ inp.smiles, e,
245
+ )
246
+
247
+ adj = drug_dose_adjuster.adjust(
248
+ baseline_dose_mg=inp.baseline_dose_mg,
249
+ bbb_permeability_score=inp.bbb_permeability_score,
250
+ drug_bbb_permeable=drug_permeable,
251
+ )
252
+ return DrugDoseAdjustmentOutput(
253
+ recommended_dose_mg=adj.recommended_dose_mg,
254
+ adjustment_factor=adj.adjustment_factor,
255
+ risk_level=adj.risk_level,
256
+ rationale=adj.rationale,
257
+ drug_bbb_permeable=drug_permeable,
258
+ )
259
+
260
+ return execute
261
+
262
+
263
  def build_default_tools(
264
  rag_index_dir: Path | None,
265
  processed_dir: Path = Path("data/processed"),
 
329
  output_model=FusionOutput,
330
  execute=lambda inp: fuse_engine(inp),
331
  ),
332
+ Tool(
333
+ name="compute_bbb_leakage_score",
334
+ description=(
335
+ "Researcher-only. Compute a BBB permeability score (0..1) "
336
+ "from a patient MRI. Mode 'heuristic_proxy' (default) uses "
337
+ "the 2D Alzheimer's classifier; 'dce_onnx' uses a real DCE "
338
+ "ONNX model when available."
339
+ ),
340
+ input_model=BBBPermeabilityMapInput,
341
+ output_model=BBBPermeabilityMapOutput,
342
+ execute=_make_bbb_permeability_executor(),
343
+ ),
344
+ Tool(
345
+ name="adjust_drug_dose",
346
+ description=(
347
+ "Researcher-only. Suggest a revised drug dose given the patient's "
348
+ "BBB permeability score and the drug's BBB classification. If "
349
+ "smiles is supplied, the BBB classifier auto-resolves whether "
350
+ "the drug crosses the BBB. Output is a research suggestion, "
351
+ "NOT medical advice."
352
+ ),
353
+ input_model=DrugDoseAdjustmentInput,
354
+ output_model=DrugDoseAdjustmentOutput,
355
+ execute=_make_dose_adjuster_executor(),
356
+ ),
357
  ]
src/api/main.py CHANGED
@@ -12,7 +12,8 @@ from src.api.routes import (
12
  explain_router,
13
  experiments_router,
14
  agent_router,
15
- fusion_router, # NEW
 
16
  )
17
  from src.api.schemas import HealthResponse
18
 
@@ -28,6 +29,7 @@ app.include_router(explain_router)
28
  app.include_router(experiments_router)
29
  app.include_router(agent_router)
30
  app.include_router(fusion_router)
 
31
 
32
 
33
  @app.get("/health", response_model=HealthResponse)
 
12
  explain_router,
13
  experiments_router,
14
  agent_router,
15
+ fusion_router,
16
+ research_router,
17
  )
18
  from src.api.schemas import HealthResponse
19
 
 
29
  app.include_router(experiments_router)
30
  app.include_router(agent_router)
31
  app.include_router(fusion_router)
32
+ app.include_router(research_router)
33
 
34
 
35
  @app.get("/health", response_model=HealthResponse)
src/api/routes.py CHANGED
@@ -27,6 +27,10 @@ from src.api.schemas import (
27
  BBBPredictResponse,
28
  BBBRequest,
29
  CalibrationContext,
 
 
 
 
30
  EEGClassProbability,
31
  EEGExplainRequest,
32
  EEGPredictRequest,
@@ -63,6 +67,7 @@ router = APIRouter(prefix="/pipeline")
63
  predict_router = APIRouter(prefix="/predict")
64
  explain_router = APIRouter(prefix="/explain")
65
  experiments_router = APIRouter(prefix="/experiments")
 
66
 
67
 
68
  def _wrap(
@@ -320,6 +325,79 @@ def predict_bbb(req: BBBPredictRequest) -> BBBPredictResponse:
320
  )
321
 
322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  @predict_router.post("/eeg", response_model=EEGPredictResponse)
324
  def predict_eeg(req: EEGPredictRequest) -> EEGPredictResponse:
325
  """Predict from EEG features using an externally-trained sklearn classifier.
 
27
  BBBPredictResponse,
28
  BBBRequest,
29
  CalibrationContext,
30
+ BBBPermeabilityMapRequest,
31
+ BBBPermeabilityMapResponse,
32
+ DrugDoseAdjustmentRequest,
33
+ DrugDoseAdjustmentResponse,
34
  EEGClassProbability,
35
  EEGExplainRequest,
36
  EEGPredictRequest,
 
67
  predict_router = APIRouter(prefix="/predict")
68
  explain_router = APIRouter(prefix="/explain")
69
  experiments_router = APIRouter(prefix="/experiments")
70
+ research_router = APIRouter(prefix="/research")
71
 
72
 
73
  def _wrap(
 
325
  )
326
 
327
 
328
+ @predict_router.post("/bbb_permeability_map", response_model=BBBPermeabilityMapResponse)
329
+ def predict_bbb_permeability_map(req: BBBPermeabilityMapRequest) -> BBBPermeabilityMapResponse:
330
+ """Compute a BBB permeability score from MRI input.
331
+
332
+ Two modes:
333
+ - heuristic_proxy (default): reuses the 2D resnet18 4-class classifier;
334
+ score = 1 - P(NonDemented). Demo-ready today.
335
+ - dce_onnx (real DCE artifact): loads an ONNX model trained on 4D DCE
336
+ data; emits a Ktrans map normalised to [0, 1]. Stub — drop the real
337
+ artifact at data/processed/bbb_permeability_dce.onnx (or set
338
+ BBB_PERMEABILITY_DCE_PATH).
339
+
340
+ Researcher-persona route — does NOT feed into the fusion engine.
341
+ """
342
+ from src.models import bbb_permeability_map as bbb_perm
343
+
344
+ try:
345
+ result = bbb_perm.compute_permeability(
346
+ input_path=Path(req.input_path),
347
+ mode=req.mode, # type: ignore[arg-type]
348
+ )
349
+ except FileNotFoundError as e:
350
+ raise HTTPException(status_code=404, detail=str(e))
351
+ except ValueError as e:
352
+ raise HTTPException(status_code=400, detail=str(e))
353
+
354
+ return BBBPermeabilityMapResponse(
355
+ permeability_score=float(result["permeability_score"]),
356
+ interpretation=str(result["interpretation"]),
357
+ method=str(result["method"]),
358
+ voxel_map_available=bool(result.get("voxel_map_available", False)),
359
+ )
360
+
361
+
362
+ @research_router.post("/drug_dose_adjustment", response_model=DrugDoseAdjustmentResponse)
363
+ def research_drug_dose_adjustment(req: DrugDoseAdjustmentRequest) -> DrugDoseAdjustmentResponse:
364
+ """Suggest a revised drug dose given patient BBB permeability + drug profile.
365
+
366
+ If `smiles` is supplied, the BBB classifier is consulted to populate
367
+ `drug_bbb_permeable` (overriding any explicit value). Researcher-persona
368
+ route — output is a research suggestion, NOT medical advice.
369
+ """
370
+ from src.research import drug_dose_adjuster
371
+
372
+ drug_permeable: bool | None = req.drug_bbb_permeable
373
+ if req.smiles:
374
+ try:
375
+ artifact = _bbb_model_path()
376
+ if artifact.exists():
377
+ model = bbb_model.load_model(artifact)
378
+ bbb_pred = bbb_model.predict_one(model, req.smiles)
379
+ drug_permeable = bool(bbb_pred["label"] == 1)
380
+ except (FileNotFoundError, ValueError, KeyError) as e:
381
+ logger.warning("could not auto-resolve BBB permeability for smiles=%s: %s", req.smiles, e)
382
+
383
+ try:
384
+ adj = drug_dose_adjuster.adjust(
385
+ baseline_dose_mg=req.baseline_dose_mg,
386
+ bbb_permeability_score=req.bbb_permeability_score,
387
+ drug_bbb_permeable=drug_permeable,
388
+ )
389
+ except ValueError as e:
390
+ raise HTTPException(status_code=400, detail=str(e))
391
+
392
+ return DrugDoseAdjustmentResponse(
393
+ recommended_dose_mg=adj.recommended_dose_mg,
394
+ adjustment_factor=adj.adjustment_factor,
395
+ risk_level=adj.risk_level,
396
+ rationale=adj.rationale,
397
+ drug_bbb_permeable=drug_permeable,
398
+ )
399
+
400
+
401
  @predict_router.post("/eeg", response_model=EEGPredictResponse)
402
  def predict_eeg(req: EEGPredictRequest) -> EEGPredictResponse:
403
  """Predict from EEG features using an externally-trained sklearn classifier.
src/api/schemas.py CHANGED
@@ -113,6 +113,61 @@ class BBBPredictResponse(BaseModel):
113
  )
114
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  class EEGPredictRequest(BaseModel):
117
  """Single-subject EEG-features prediction request."""
118
  features: list[float] = Field(
 
113
  )
114
 
115
 
116
+ class BBBPermeabilityMapRequest(BaseModel):
117
+ """Compute a per-patient BBB permeability score from MRI input."""
118
+ input_path: str = Field(
119
+ ...,
120
+ description=(
121
+ "Path to MRI input. heuristic_proxy mode: 2D image (.png/.jpg) "
122
+ "consumed by the resnet18 4-class Alzheimer's classifier. "
123
+ "dce_onnx mode: 4D NIfTI (X,Y,Z,T) for the DCE Ktrans pipeline."
124
+ ),
125
+ )
126
+ mode: str = Field(
127
+ "heuristic_proxy",
128
+ description="'heuristic_proxy' (default, demo-ready) | 'dce_onnx' (real DCE artifact)",
129
+ )
130
+
131
+
132
+ class BBBPermeabilityMapResponse(BaseModel):
133
+ """Researcher-persona BBB leakage payload."""
134
+ permeability_score: float = Field(..., ge=0.0, le=1.0,
135
+ description="Scalar in [0,1]; 0=intact, 1=fully leaky.")
136
+ interpretation: str = Field(..., description="'BBB intact' | 'mild leakage' | 'moderate leakage' | 'severe leakage'")
137
+ method: str = Field(..., description="'heuristic_proxy' | 'dce_onnx'")
138
+ voxel_map_available: bool = False
139
+
140
+
141
+ class DrugDoseAdjustmentRequest(BaseModel):
142
+ """Researcher-persona dose-revision request, given patient BBB + drug profile."""
143
+ smiles: str | None = Field(
144
+ None,
145
+ description=(
146
+ "Optional SMILES. When provided, the route auto-resolves "
147
+ "drug_bbb_permeable via the BBB classifier (overrides any "
148
+ "explicit value below)."
149
+ ),
150
+ )
151
+ baseline_dose_mg: float = Field(..., gt=0.0, description="Standard adult dose in mg.")
152
+ bbb_permeability_score: float = Field(..., ge=0.0, le=1.0)
153
+ drug_bbb_permeable: bool | None = Field(
154
+ None,
155
+ description="If known, whether the drug crosses the BBB. Auto-resolved when smiles is given.",
156
+ )
157
+
158
+
159
+ class DrugDoseAdjustmentResponse(BaseModel):
160
+ """Recommended dose with rationale. NOT medical advice."""
161
+ recommended_dose_mg: float
162
+ adjustment_factor: float = Field(..., ge=0.0, le=1.0)
163
+ risk_level: str = Field(..., description="'low' | 'moderate' | 'high'")
164
+ rationale: str
165
+ drug_bbb_permeable: bool | None = Field(
166
+ None,
167
+ description="Echoed back; reflects what was used in the calculation (auto-resolved if smiles was given).",
168
+ )
169
+
170
+
171
  class EEGPredictRequest(BaseModel):
172
  """Single-subject EEG-features prediction request."""
173
  features: list[float] = Field(
src/frontend/app.py CHANGED
@@ -1678,6 +1678,94 @@ def _render_combat_diagnostics(result: dict) -> None:
1678
  st.error(f"Cannot reach FastAPI: {e!r}")
1679
 
1680
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1681
  def _render_ai_assistant_tab() -> None:
1682
  """Chat-style explainer for the most recent BBB prediction."""
1683
  _render_section(
@@ -1889,10 +1977,11 @@ def main() -> None:
1889
  "Run `uvicorn src.api.main:app --port 8000` or `docker compose up`."
1890
  )
1891
 
1892
- bbb_tab, eeg_tab, mri_tab, assistant_tab, experiments_tab, agent_tab = st.tabs([
1893
  "Molecule",
1894
  "Signal",
1895
  "Image",
 
1896
  "AI Assistant",
1897
  "Experiments",
1898
  "🤖 Agent",
@@ -1904,6 +1993,8 @@ def main() -> None:
1904
  _render_eeg_tab()
1905
  with mri_tab:
1906
  _render_mri_tab()
 
 
1907
  with assistant_tab:
1908
  _render_ai_assistant_tab()
1909
  with experiments_tab:
 
1678
  st.error(f"Cannot reach FastAPI: {e!r}")
1679
 
1680
 
1681
+ def _render_researcher_tab() -> None:
1682
+ """Drug researcher view: BBB permeability map + dose adjustment."""
1683
+ st.markdown("### Drug Researcher")
1684
+ st.caption(
1685
+ "DCE-MRI inspired BBB leakage score → revised dose suggestion. "
1686
+ "Output is a research signal, NOT medical advice."
1687
+ )
1688
+
1689
+ col_left, col_right = st.columns(2)
1690
+ with col_left:
1691
+ st.markdown("**1. Patient BBB permeability**")
1692
+ mri_path = st.text_input(
1693
+ "MRI image path (server-side)",
1694
+ "tests/fixtures/mri_sample/subject_0_axial.png",
1695
+ key="researcher_mri_path",
1696
+ )
1697
+ mode = st.selectbox(
1698
+ "Scoring mode",
1699
+ ["heuristic_proxy", "dce_onnx"],
1700
+ index=0,
1701
+ key="researcher_perm_mode",
1702
+ help="heuristic_proxy uses the 2D classifier; dce_onnx requires a trained DCE artifact.",
1703
+ )
1704
+ if st.button("Compute BBB leakage score", key="researcher_compute_perm"):
1705
+ with st.spinner("Running BBB permeability scorer..."):
1706
+ try:
1707
+ result = _post(
1708
+ "/predict/bbb_permeability_map",
1709
+ {"input_path": mri_path, "mode": mode},
1710
+ timeout=60.0,
1711
+ )
1712
+ except httpx.HTTPStatusError as e:
1713
+ st.error(f"BBB permeability failed (HTTP {e.response.status_code}): {e.response.text}")
1714
+ except httpx.RequestError as e:
1715
+ st.error(f"Cannot reach FastAPI: {e!r}")
1716
+ else:
1717
+ st.session_state["researcher_perm"] = result
1718
+ st.metric(
1719
+ label=result.get("interpretation", "BBB"),
1720
+ value=f"{float(result['permeability_score']) * 100:.1f}%",
1721
+ help=f"method={result.get('method', '?')}",
1722
+ )
1723
+
1724
+ with col_right:
1725
+ st.markdown("**2. Drug + baseline dose**")
1726
+ smiles = st.text_input("SMILES", "CCO", key="researcher_smiles")
1727
+ baseline = st.number_input(
1728
+ "Baseline dose (mg)",
1729
+ min_value=0.1, max_value=2000.0, value=100.0, step=10.0,
1730
+ key="researcher_baseline",
1731
+ )
1732
+ score_default = float(
1733
+ st.session_state.get("researcher_perm", {}).get("permeability_score", 0.0)
1734
+ )
1735
+ score = st.number_input(
1736
+ "BBB permeability score",
1737
+ min_value=0.0, max_value=1.0, value=score_default, step=0.05,
1738
+ key="researcher_score",
1739
+ help="Auto-fills from the BBB leakage score above; override manually if you want.",
1740
+ )
1741
+ if st.button("Suggest revised dose", key="researcher_compute_dose"):
1742
+ payload = {
1743
+ "smiles": smiles or None,
1744
+ "baseline_dose_mg": float(baseline),
1745
+ "bbb_permeability_score": float(score),
1746
+ }
1747
+ with st.spinner("Computing dose adjustment..."):
1748
+ try:
1749
+ result = _post("/research/drug_dose_adjustment", payload, timeout=30.0)
1750
+ except httpx.HTTPStatusError as e:
1751
+ st.error(f"Dose adjustment failed (HTTP {e.response.status_code}): {e.response.text}")
1752
+ except httpx.RequestError as e:
1753
+ st.error(f"Cannot reach FastAPI: {e!r}")
1754
+ else:
1755
+ risk = result.get("risk_level", "unknown")
1756
+ risk_emoji = {"low": "🟢", "moderate": "🟡", "high": "🔴"}.get(risk, "⚪️")
1757
+ st.metric(
1758
+ label=f"{risk_emoji} Recommended dose",
1759
+ value=f"{result['recommended_dose_mg']:.1f} mg",
1760
+ delta=f"{(result['adjustment_factor'] - 1.0) * 100:+.0f}%",
1761
+ delta_color="inverse",
1762
+ )
1763
+ drug_perm = result.get("drug_bbb_permeable")
1764
+ if drug_perm is not None:
1765
+ st.caption(f"Drug BBB-permeable: **{drug_perm}**")
1766
+ st.info(result.get("rationale", ""))
1767
+
1768
+
1769
  def _render_ai_assistant_tab() -> None:
1770
  """Chat-style explainer for the most recent BBB prediction."""
1771
  _render_section(
 
1977
  "Run `uvicorn src.api.main:app --port 8000` or `docker compose up`."
1978
  )
1979
 
1980
+ bbb_tab, eeg_tab, mri_tab, researcher_tab, assistant_tab, experiments_tab, agent_tab = st.tabs([
1981
  "Molecule",
1982
  "Signal",
1983
  "Image",
1984
+ "Researcher",
1985
  "AI Assistant",
1986
  "Experiments",
1987
  "🤖 Agent",
 
1993
  _render_eeg_tab()
1994
  with mri_tab:
1995
  _render_mri_tab()
1996
+ with researcher_tab:
1997
+ _render_researcher_tab()
1998
  with assistant_tab:
1999
  _render_ai_assistant_tab()
2000
  with experiments_tab:
src/models/bbb_permeability_map.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BBB Permeability Map / Score from MRI input.
2
+
3
+ Two modes:
4
+ - `heuristic_proxy` (default, demo-ready): reuses the 2D resnet18 4-class
5
+ Alzheimer's classifier output. Score = 1 - P(NonDemented). Anchored in the
6
+ published correlation between disease severity and BBB breakdown.
7
+ - `dce_onnx` (real-DCE artifact, future): loads an ONNX model trained on
8
+ 4D DCE-MRI inputs that emit per-voxel Ktrans maps. Stub for now; works
9
+ when the artifact drops in.
10
+
11
+ Researcher-persona module. Does NOT feed into the fusion engine — fusion's
12
+ 'BBB is NOT a modality' rule is preserved. The only legitimate place where
13
+ BBB and MRI couple in this platform.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from pathlib import Path
19
+ from typing import Any, Literal
20
+
21
+ import numpy as np
22
+
23
+ from src.core.logger import get_logger
24
+
25
+ logger = get_logger(__name__)
26
+
27
+ PermeabilityMode = Literal["heuristic_proxy", "dce_onnx"]
28
+ DEFAULT_MODE: PermeabilityMode = "heuristic_proxy"
29
+
30
+
31
+ def _interpret(score: float) -> str:
32
+ if score < 0.2:
33
+ return "BBB intact"
34
+ if score < 0.4:
35
+ return "mild leakage"
36
+ if score < 0.7:
37
+ return "moderate leakage"
38
+ return "severe leakage"
39
+
40
+
41
+ def compute_from_classifier_probs(probabilities: list[dict[str, Any]]) -> float:
42
+ """Heuristic: 1 - P(NonDemented) from a 4-class probability list.
43
+
44
+ Accepts the standard prediction-dict shape used across this repo
45
+ (`probabilities=[{"label_text", "probability"}, ...]`).
46
+ """
47
+ p_nondemented = 0.0
48
+ for entry in probabilities:
49
+ if str(entry.get("label_text", "")).lower() == "nondemented":
50
+ p_nondemented = float(entry.get("probability", 0.0))
51
+ break
52
+ score = max(0.0, min(1.0, 1.0 - p_nondemented))
53
+ return float(score)
54
+
55
+
56
+ def heuristic_proxy_score(input_path: Path, checkpoint_path: Path) -> dict[str, Any]:
57
+ """Run the 2D resnet18 classifier and derive the permeability score from
58
+ its NonDemented probability.
59
+ """
60
+ from src.models import mri_dl_2d
61
+
62
+ model = mri_dl_2d.load(checkpoint_path)
63
+ pred = mri_dl_2d.predict_image(model, input_path)
64
+ score = compute_from_classifier_probs(pred["probabilities"])
65
+ return {
66
+ "permeability_score": score,
67
+ "interpretation": _interpret(score),
68
+ "method": "heuristic_proxy",
69
+ "voxel_map_available": False,
70
+ "source_class_probabilities": pred["probabilities"],
71
+ }
72
+
73
+
74
+ def dce_onnx_score(input_path: Path, checkpoint_path: Path) -> dict[str, Any]:
75
+ """Load a DCE-MRI ONNX model and compute per-voxel Ktrans → scalar score.
76
+
77
+ Contract for the future artifact:
78
+ - Input: 4D NIfTI `(X, Y, Z, T)` with at least one timepoint.
79
+ - Output: 3D Ktrans map (mL/min/100g). We normalise to `[0, 1]` by
80
+ dividing by a clinically-conservative cap (`_DCE_KTRANS_CAP_MAX`)
81
+ and clipping. Mean over the brain mask becomes the scalar score.
82
+
83
+ No real artifact ships with the repo — this raises a clear error
84
+ explaining the contract until one lands at `data/processed/bbb_permeability_dce.onnx`
85
+ (override via `BBB_PERMEABILITY_DCE_PATH`).
86
+ """
87
+ checkpoint_path = Path(checkpoint_path)
88
+ if not checkpoint_path.exists():
89
+ raise FileNotFoundError(
90
+ f"DCE-MRI BBB permeability artifact not found at {checkpoint_path}. "
91
+ "Train and export an ONNX model that consumes a 4D DCE volume "
92
+ "(X, Y, Z, T) and emits a 3D Ktrans map; drop it at this path or "
93
+ "set BBB_PERMEABILITY_DCE_PATH."
94
+ )
95
+ import nibabel as nib
96
+ import onnxruntime as ort
97
+
98
+ img = nib.load(str(input_path))
99
+ arr = np.asarray(img.get_fdata(dtype=np.float32), dtype=np.float32)
100
+ if arr.ndim != 4:
101
+ raise ValueError(
102
+ f"DCE-MRI mode expects a 4D NIfTI (X,Y,Z,T); got shape {arr.shape}."
103
+ )
104
+ session = ort.InferenceSession(str(checkpoint_path), providers=["CPUExecutionProvider"])
105
+ input_name = session.get_inputs()[0].name
106
+ ktrans = session.run(None, {input_name: arr[np.newaxis, ...]})[0]
107
+ # Normalise to [0, 1]; clinically-conservative cap of 0.5 mL/min/100g.
108
+ _DCE_KTRANS_CAP = 0.5
109
+ normalised = np.clip(ktrans / _DCE_KTRANS_CAP, 0.0, 1.0).astype(np.float32)
110
+ score = float(np.mean(normalised))
111
+ return {
112
+ "permeability_score": score,
113
+ "interpretation": _interpret(score),
114
+ "method": "dce_onnx",
115
+ "voxel_map_available": True,
116
+ "voxel_map_shape": list(normalised.shape),
117
+ }
118
+
119
+
120
+ def compute_permeability(
121
+ input_path: Path,
122
+ mode: PermeabilityMode = DEFAULT_MODE,
123
+ checkpoint_path: Path | None = None,
124
+ ) -> dict[str, Any]:
125
+ """Dispatch to the requested mode and return a unified payload."""
126
+ input_path = Path(input_path)
127
+ if not input_path.exists():
128
+ raise FileNotFoundError(f"MRI input not found: {input_path}")
129
+
130
+ if mode == "heuristic_proxy":
131
+ ckpt = checkpoint_path or Path(os.environ.get(
132
+ "MRI_MODEL_PATH_2D", "data/processed/mri_dl_2d/best_model.pt",
133
+ ))
134
+ return heuristic_proxy_score(input_path, ckpt)
135
+
136
+ if mode == "dce_onnx":
137
+ ckpt = checkpoint_path or Path(os.environ.get(
138
+ "BBB_PERMEABILITY_DCE_PATH",
139
+ "data/processed/bbb_permeability_dce.onnx",
140
+ ))
141
+ return dce_onnx_score(input_path, ckpt)
142
+
143
+ raise ValueError(
144
+ f"unknown BBB permeability mode={mode!r}; expected one of "
145
+ "('heuristic_proxy', 'dce_onnx')"
146
+ )
src/research/__init__.py ADDED
File without changes
src/research/drug_dose_adjuster.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Drug-dose adjustment given patient BBB permeability + drug BBB classification.
2
+
3
+ Pure-function logic. Researcher-persona module — does NOT touch the fusion
4
+ engine. Output is a research suggestion, NOT medical advice.
5
+
6
+ Decision matrix:
7
+
8
+ BBB intact (perm < 0.2) -> factor=1.0, risk=low
9
+ BBB leaky, drug BBB-permeable -> factor=max(0.3, 1 - 0.7*perm),
10
+ risk=high if perm > 0.6 else moderate
11
+ BBB leaky, drug NOT permeable -> factor=max(0.6, 1 - 0.4*perm),
12
+ risk=moderate
13
+ drug_permeable=None (unknown) -> treat as permeable (safer)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Literal
19
+
20
+ RiskLevel = Literal["low", "moderate", "high"]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class DoseAdjustment:
25
+ recommended_dose_mg: float
26
+ adjustment_factor: float # 0..1; 1.0 = no change
27
+ risk_level: RiskLevel
28
+ rationale: str
29
+
30
+
31
+ # Threshold below which BBB is considered intact and no adjustment applies.
32
+ _INTACT_THRESHOLD = 0.2
33
+
34
+ # Floor on the adjustment factor (we never recommend going below this fraction
35
+ # of the baseline dose — anything more aggressive belongs to a clinician).
36
+ _PERMEABLE_DRUG_MIN_FACTOR = 0.3
37
+ _NON_PERMEABLE_DRUG_MIN_FACTOR = 0.6
38
+
39
+ # Slope on the linear adjustment.
40
+ _PERMEABLE_DRUG_SLOPE = 0.7
41
+ _NON_PERMEABLE_DRUG_SLOPE = 0.4
42
+
43
+
44
+ def adjust(
45
+ baseline_dose_mg: float,
46
+ bbb_permeability_score: float,
47
+ drug_bbb_permeable: bool | None = None,
48
+ ) -> DoseAdjustment:
49
+ """Return a DoseAdjustment for the (patient, drug) pair."""
50
+ if baseline_dose_mg <= 0.0:
51
+ raise ValueError(f"baseline_dose_mg must be > 0; got {baseline_dose_mg}")
52
+ if not 0.0 <= bbb_permeability_score <= 1.0:
53
+ raise ValueError(
54
+ f"bbb_permeability_score must be in [0, 1]; got {bbb_permeability_score}"
55
+ )
56
+
57
+ if bbb_permeability_score < _INTACT_THRESHOLD:
58
+ return DoseAdjustment(
59
+ recommended_dose_mg=baseline_dose_mg,
60
+ adjustment_factor=1.0,
61
+ risk_level="low",
62
+ rationale=(
63
+ f"BBB permeability score {bbb_permeability_score:.2f} is below the "
64
+ f"intact threshold ({_INTACT_THRESHOLD:.2f}). No dose adjustment "
65
+ "recommended. Research suggestion, not medical advice."
66
+ ),
67
+ )
68
+
69
+ # Treat unknown permeability conservatively as 'permeable'.
70
+ permeable = True if drug_bbb_permeable is None else bool(drug_bbb_permeable)
71
+
72
+ if permeable:
73
+ factor = max(
74
+ _PERMEABLE_DRUG_MIN_FACTOR,
75
+ 1.0 - _PERMEABLE_DRUG_SLOPE * bbb_permeability_score,
76
+ )
77
+ risk: RiskLevel = "high" if bbb_permeability_score > 0.6 else "moderate"
78
+ permeable_note = (
79
+ "Drug is BBB-permeable" if drug_bbb_permeable is True
80
+ else "Drug BBB permeability is unknown (treated as permeable for safety)"
81
+ )
82
+ else:
83
+ factor = max(
84
+ _NON_PERMEABLE_DRUG_MIN_FACTOR,
85
+ 1.0 - _NON_PERMEABLE_DRUG_SLOPE * bbb_permeability_score,
86
+ )
87
+ risk = "moderate"
88
+ permeable_note = "Drug is normally BBB-excluded"
89
+
90
+ recommended = baseline_dose_mg * factor
91
+ rationale = (
92
+ f"BBB permeability score {bbb_permeability_score:.2f} indicates "
93
+ f"compromised barrier. {permeable_note}; recommended dose reduced "
94
+ f"to {factor*100:.0f}% of baseline ({recommended:.1f} mg of "
95
+ f"{baseline_dose_mg:.1f} mg). Risk level: {risk}. "
96
+ "Research suggestion, not medical advice."
97
+ )
98
+ return DoseAdjustment(
99
+ recommended_dose_mg=float(recommended),
100
+ adjustment_factor=float(factor),
101
+ risk_level=risk,
102
+ rationale=rationale,
103
+ )
tests/agents/test_tools.py CHANGED
@@ -70,7 +70,7 @@ class TestTool:
70
 
71
 
72
  class TestBuildDefaultTools:
73
- def test_default_set_has_four_tools(self, tmp_path: Path) -> None:
74
  # build with placeholder paths; tools won't be invoked here
75
  tools = build_default_tools(rag_index_dir=None)
76
  names = {t.name for t in tools}
@@ -80,6 +80,8 @@ class TestBuildDefaultTools:
80
  "run_mri_pipeline",
81
  "retrieve_context",
82
  "run_fusion",
 
 
83
  }
84
 
85
  def test_each_tool_has_pydantic_input_model(self) -> None:
@@ -116,8 +118,8 @@ class TestBuildDefaultTools:
116
  def test_default_processed_dir_when_omitted(self) -> None:
117
  # backwards-compat: omitting processed_dir keeps existing behavior
118
  tools = build_default_tools(rag_index_dir=None)
119
- # just ensure no exception and 5 tools returned
120
- assert len(tools) == 5
121
 
122
  def test_bbb_executor_translates_httpexception_to_valueerror(self) -> None:
123
  from fastapi import HTTPException
 
70
 
71
 
72
  class TestBuildDefaultTools:
73
+ def test_default_set_has_seven_tools(self, tmp_path: Path) -> None:
74
  # build with placeholder paths; tools won't be invoked here
75
  tools = build_default_tools(rag_index_dir=None)
76
  names = {t.name for t in tools}
 
80
  "run_mri_pipeline",
81
  "retrieve_context",
82
  "run_fusion",
83
+ "compute_bbb_leakage_score",
84
+ "adjust_drug_dose",
85
  }
86
 
87
  def test_each_tool_has_pydantic_input_model(self) -> None:
 
118
  def test_default_processed_dir_when_omitted(self) -> None:
119
  # backwards-compat: omitting processed_dir keeps existing behavior
120
  tools = build_default_tools(rag_index_dir=None)
121
+ # just ensure no exception and 7 tools returned
122
+ assert len(tools) == 7
123
 
124
  def test_bbb_executor_translates_httpexception_to_valueerror(self) -> None:
125
  from fastapi import HTTPException
tests/api/test_bbb_research_routes.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration tests for /predict/bbb_permeability_map and /research/drug_dose_adjustment."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import pytest
8
+ from fastapi.testclient import TestClient
9
+ from PIL import Image
10
+
11
+ from src.api.main import app
12
+ from tests.fixtures.build_dummy_resnet18_2d import build as build_dummy_2d
13
+
14
+
15
+ def _png(path: Path) -> Path:
16
+ arr = (np.random.RandomState(0).rand(170, 170, 3) * 255).astype(np.uint8)
17
+ Image.fromarray(arr, mode="RGB").save(str(path))
18
+ return path
19
+
20
+
21
+ @pytest.fixture()
22
+ def client_proxy(monkeypatch, tmp_path):
23
+ ckpt = build_dummy_2d(tmp_path / "best.pt")
24
+ monkeypatch.setenv("MRI_MODEL_PATH_2D", str(ckpt))
25
+ return TestClient(app), tmp_path
26
+
27
+
28
+ class TestBBBPermeabilityMapRoute:
29
+ def test_heuristic_proxy_happy_path(self, client_proxy) -> None:
30
+ client, tmp_path = client_proxy
31
+ img = _png(tmp_path / "scan.png")
32
+ r = client.post(
33
+ "/predict/bbb_permeability_map",
34
+ json={"input_path": str(img), "mode": "heuristic_proxy"},
35
+ )
36
+ assert r.status_code == 200, r.text
37
+ data = r.json()
38
+ assert 0.0 <= data["permeability_score"] <= 1.0
39
+ assert data["interpretation"] in {
40
+ "BBB intact", "mild leakage", "moderate leakage", "severe leakage",
41
+ }
42
+ assert data["method"] == "heuristic_proxy"
43
+ assert data["voxel_map_available"] is False
44
+
45
+ def test_unknown_mode_returns_400(self, client_proxy) -> None:
46
+ client, tmp_path = client_proxy
47
+ img = _png(tmp_path / "scan.png")
48
+ r = client.post(
49
+ "/predict/bbb_permeability_map",
50
+ json={"input_path": str(img), "mode": "bogus_mode"},
51
+ )
52
+ assert r.status_code == 400
53
+
54
+ def test_missing_input_returns_404(self, client_proxy) -> None:
55
+ client, tmp_path = client_proxy
56
+ r = client.post(
57
+ "/predict/bbb_permeability_map",
58
+ json={"input_path": str(tmp_path / "missing.png"), "mode": "heuristic_proxy"},
59
+ )
60
+ assert r.status_code == 404
61
+
62
+
63
+ class TestDrugDoseAdjustmentRoute:
64
+ def test_intact_bbb_returns_baseline(self) -> None:
65
+ client = TestClient(app)
66
+ r = client.post("/research/drug_dose_adjustment", json={
67
+ "baseline_dose_mg": 100.0,
68
+ "bbb_permeability_score": 0.05,
69
+ "drug_bbb_permeable": True,
70
+ })
71
+ assert r.status_code == 200, r.text
72
+ data = r.json()
73
+ assert data["recommended_dose_mg"] == pytest.approx(100.0)
74
+ assert data["risk_level"] == "low"
75
+
76
+ def test_leaky_bbb_permeable_drug_reduced(self) -> None:
77
+ client = TestClient(app)
78
+ r = client.post("/research/drug_dose_adjustment", json={
79
+ "baseline_dose_mg": 100.0,
80
+ "bbb_permeability_score": 0.5,
81
+ "drug_bbb_permeable": True,
82
+ })
83
+ assert r.status_code == 200
84
+ data = r.json()
85
+ assert data["recommended_dose_mg"] == pytest.approx(65.0)
86
+ assert data["risk_level"] == "moderate"
87
+ assert "research suggestion" in data["rationale"].lower()
88
+
89
+ def test_severe_leakage_high_risk(self) -> None:
90
+ client = TestClient(app)
91
+ r = client.post("/research/drug_dose_adjustment", json={
92
+ "baseline_dose_mg": 100.0,
93
+ "bbb_permeability_score": 0.85,
94
+ "drug_bbb_permeable": True,
95
+ })
96
+ data = r.json()
97
+ assert data["risk_level"] == "high"
98
+
99
+ def test_negative_baseline_returns_422(self) -> None:
100
+ client = TestClient(app)
101
+ r = client.post("/research/drug_dose_adjustment", json={
102
+ "baseline_dose_mg": -1.0,
103
+ "bbb_permeability_score": 0.5,
104
+ })
105
+ assert r.status_code == 422 # pydantic gt=0.0 validation
106
+
107
+ def test_score_out_of_range_returns_422(self) -> None:
108
+ client = TestClient(app)
109
+ r = client.post("/research/drug_dose_adjustment", json={
110
+ "baseline_dose_mg": 100.0,
111
+ "bbb_permeability_score": 1.5,
112
+ })
113
+ assert r.status_code == 422
tests/models/test_bbb_permeability_map.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for src.models.bbb_permeability_map — researcher BBB scorer."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import pytest
8
+ from PIL import Image
9
+
10
+ from src.models import bbb_permeability_map as bbb_perm
11
+ from tests.fixtures.build_dummy_resnet18_2d import build as build_dummy_2d
12
+
13
+
14
+ def _png(path: Path, size: tuple[int, int] = (170, 170)) -> Path:
15
+ arr = (np.random.RandomState(0).rand(size[1], size[0], 3) * 255).astype(np.uint8)
16
+ Image.fromarray(arr, mode="RGB").save(str(path))
17
+ return path
18
+
19
+
20
+ class TestComputeFromClassifierProbs:
21
+ def test_full_nondemented_yields_zero_score(self) -> None:
22
+ probs = [
23
+ {"label_text": "MildDemented", "probability": 0.0},
24
+ {"label_text": "ModerateDemented", "probability": 0.0},
25
+ {"label_text": "NonDemented", "probability": 1.0},
26
+ {"label_text": "VeryMildDemented", "probability": 0.0},
27
+ ]
28
+ assert bbb_perm.compute_from_classifier_probs(probs) == pytest.approx(0.0)
29
+
30
+ def test_no_nondemented_yields_one(self) -> None:
31
+ probs = [
32
+ {"label_text": "MildDemented", "probability": 0.5},
33
+ {"label_text": "ModerateDemented", "probability": 0.5},
34
+ ]
35
+ assert bbb_perm.compute_from_classifier_probs(probs) == pytest.approx(1.0)
36
+
37
+ def test_partial_yields_complement(self) -> None:
38
+ probs = [
39
+ {"label_text": "NonDemented", "probability": 0.7},
40
+ {"label_text": "MildDemented", "probability": 0.3},
41
+ ]
42
+ assert bbb_perm.compute_from_classifier_probs(probs) == pytest.approx(0.3)
43
+
44
+ def test_case_insensitive_label_match(self) -> None:
45
+ probs = [{"label_text": "nondemented", "probability": 0.6}]
46
+ assert bbb_perm.compute_from_classifier_probs(probs) == pytest.approx(0.4)
47
+
48
+
49
+ class TestComputePermeability:
50
+ def test_heuristic_proxy_with_dummy_2d(self, tmp_path: Path) -> None:
51
+ ckpt = build_dummy_2d(tmp_path / "best.pt")
52
+ img = _png(tmp_path / "scan.png")
53
+ result = bbb_perm.compute_permeability(
54
+ input_path=img, mode="heuristic_proxy", checkpoint_path=ckpt,
55
+ )
56
+ assert 0.0 <= result["permeability_score"] <= 1.0
57
+ assert result["interpretation"] in {
58
+ "BBB intact", "mild leakage", "moderate leakage", "severe leakage",
59
+ }
60
+ assert result["method"] == "heuristic_proxy"
61
+ assert result["voxel_map_available"] is False
62
+
63
+ def test_unknown_mode_raises(self, tmp_path: Path) -> None:
64
+ img = _png(tmp_path / "scan.png")
65
+ with pytest.raises(ValueError, match="unknown BBB permeability mode"):
66
+ bbb_perm.compute_permeability(input_path=img, mode="bogus") # type: ignore[arg-type]
67
+
68
+ def test_missing_input_raises(self, tmp_path: Path) -> None:
69
+ with pytest.raises(FileNotFoundError, match="MRI input not found"):
70
+ bbb_perm.compute_permeability(input_path=tmp_path / "nope.png")
71
+
72
+ def test_dce_mode_without_artifact_raises(self, tmp_path: Path) -> None:
73
+ # Existence check on the input is the first gate; placeholder file is fine.
74
+ img = tmp_path / "fake_dce.nii.gz"
75
+ img.write_bytes(b"")
76
+ with pytest.raises(FileNotFoundError, match="DCE-MRI BBB permeability artifact"):
77
+ bbb_perm.compute_permeability(
78
+ input_path=img, mode="dce_onnx",
79
+ checkpoint_path=tmp_path / "missing.onnx",
80
+ )
81
+
82
+ def test_interpretation_thresholds(self) -> None:
83
+ assert bbb_perm._interpret(0.10) == "BBB intact"
84
+ assert bbb_perm._interpret(0.30) == "mild leakage"
85
+ assert bbb_perm._interpret(0.55) == "moderate leakage"
86
+ assert bbb_perm._interpret(0.80) == "severe leakage"
tests/research/__init__.py ADDED
File without changes
tests/research/test_drug_dose_adjuster.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for src.research.drug_dose_adjuster — pure-function dose revision."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from src.research.drug_dose_adjuster import adjust
7
+
8
+
9
+ class TestAdjust:
10
+ # --- BBB intact ---------------------------------------------------------
11
+
12
+ def test_intact_bbb_no_change_permeable_drug(self) -> None:
13
+ out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.05, drug_bbb_permeable=True)
14
+ assert out.recommended_dose_mg == pytest.approx(100.0)
15
+ assert out.adjustment_factor == 1.0
16
+ assert out.risk_level == "low"
17
+
18
+ def test_intact_bbb_no_change_non_permeable_drug(self) -> None:
19
+ out = adjust(baseline_dose_mg=200.0, bbb_permeability_score=0.0, drug_bbb_permeable=False)
20
+ assert out.adjustment_factor == 1.0
21
+ assert out.risk_level == "low"
22
+
23
+ # --- BBB leaky, drug permeable -----------------------------------------
24
+
25
+ def test_leaky_bbb_permeable_drug_reduces_dose(self) -> None:
26
+ out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.5, drug_bbb_permeable=True)
27
+ # factor = max(0.3, 1 - 0.7*0.5) = max(0.3, 0.65) = 0.65
28
+ assert out.adjustment_factor == pytest.approx(0.65)
29
+ assert out.recommended_dose_mg == pytest.approx(65.0)
30
+ assert out.risk_level == "moderate"
31
+
32
+ def test_severe_leakage_permeable_drug_high_risk(self) -> None:
33
+ out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.8, drug_bbb_permeable=True)
34
+ assert out.risk_level == "high"
35
+ # factor = max(0.3, 1 - 0.7*0.8) = max(0.3, 0.44) = 0.44
36
+ assert out.adjustment_factor == pytest.approx(0.44)
37
+
38
+ def test_safety_floor_not_breached(self) -> None:
39
+ # At perm=1.0: 1 - 0.7 = 0.3 — the floor.
40
+ out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=1.0, drug_bbb_permeable=True)
41
+ assert out.adjustment_factor >= 0.3 - 1e-9
42
+ # And anything above 1.0 (clipped by validator) — won't get there.
43
+
44
+ # --- BBB leaky, drug NOT permeable -------------------------------------
45
+
46
+ def test_leaky_bbb_non_permeable_drug_mild_reduction(self) -> None:
47
+ out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.5, drug_bbb_permeable=False)
48
+ # factor = max(0.6, 1 - 0.4*0.5) = max(0.6, 0.8) = 0.8
49
+ assert out.adjustment_factor == pytest.approx(0.8)
50
+ assert out.risk_level == "moderate"
51
+
52
+ def test_non_permeable_drug_safety_floor(self) -> None:
53
+ out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=1.0, drug_bbb_permeable=False)
54
+ assert out.adjustment_factor == pytest.approx(0.6)
55
+
56
+ # --- Drug permeability unknown -----------------------------------------
57
+
58
+ def test_unknown_permeability_treated_as_permeable(self) -> None:
59
+ out_unknown = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.5, drug_bbb_permeable=None)
60
+ out_perm = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.5, drug_bbb_permeable=True)
61
+ assert out_unknown.adjustment_factor == pytest.approx(out_perm.adjustment_factor)
62
+
63
+ def test_unknown_permeability_rationale_says_so(self) -> None:
64
+ out = adjust(baseline_dose_mg=100.0, bbb_permeability_score=0.5, drug_bbb_permeable=None)
65
+ assert "unknown" in out.rationale.lower()
66
+
67
+ # --- Validation ---------------------------------------------------------
68
+
69
+ def test_zero_baseline_raises(self) -> None:
70
+ with pytest.raises(ValueError, match="baseline_dose_mg must be > 0"):
71
+ adjust(baseline_dose_mg=0.0, bbb_permeability_score=0.5)
72
+
73
+ def test_negative_baseline_raises(self) -> None:
74
+ with pytest.raises(ValueError, match="baseline_dose_mg must be > 0"):
75
+ adjust(baseline_dose_mg=-10.0, bbb_permeability_score=0.5)
76
+
77
+ def test_score_out_of_range_raises(self) -> None:
78
+ with pytest.raises(ValueError, match="bbb_permeability_score must be in"):
79
+ adjust(baseline_dose_mg=100.0, bbb_permeability_score=1.5)
80
+ with pytest.raises(ValueError, match="bbb_permeability_score must be in"):
81
+ adjust(baseline_dose_mg=100.0, bbb_permeability_score=-0.1)
82
+
83
+ # --- Monotonicity ------------------------------------------------------
84
+
85
+ def test_increasing_leakage_lowers_recommended_dose_for_permeable_drug(self) -> None:
86
+ scores = [0.25, 0.4, 0.55, 0.7, 0.85]
87
+ doses = [
88
+ adjust(100.0, s, drug_bbb_permeable=True).recommended_dose_mg
89
+ for s in scores
90
+ ]
91
+ # Strictly non-increasing.
92
+ assert all(a >= b - 1e-9 for a, b in zip(doses, doses[1:]))
93
+
94
+ # --- Rationale always says it's not medical advice --------------------
95
+
96
+ def test_rationale_disclaims_medical_advice(self) -> None:
97
+ for score in [0.0, 0.3, 0.7, 1.0]:
98
+ out = adjust(100.0, score, drug_bbb_permeable=True)
99
+ assert "research suggestion" in out.rationale.lower()
100
+ assert "medical advice" in out.rationale.lower()