mekosotto Claude Opus 4.7 (1M context) commited on
Commit
44397bd
·
1 Parent(s): 0435d80

docs(plan): clinical platform roadmap + fusion engine plan

Browse files

Roadmap indexes six independent sub-projects with locked-in independence
guarantees: BBB / MRI / EEG / fusion pipelines all run standalone, with
sub-plan #3 (BBB-from-MRI) the only legitimate bridge.

Fusion engine plan is the foundation: 8 TDD tasks producing a pure-Python
multi-modal combiner (MRI + EEG + clinical scores -> per-disease confidence
with attribution) plus FastAPI route and agent tool registration. BBB is
explicitly excluded as a fusion modality and pinned by a regression test.

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

docs/superpowers/plans/2026-05-02-clinical-platform-roadmap.md ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Clinical Decision Platform — Roadmap
2
+
3
+ > **For agentic workers:** This is an INDEX, not an implementation plan. Each sub-plan listed below is itself a complete plan to be executed via `superpowers:subagent-driven-development` or `superpowers:executing-plans`.
4
+
5
+ **Vision.** A multi-modal Alzheimer's / Parkinson's decision platform with three personas — **Doctor, Patient, Researcher** — sharing one backend.
6
+
7
+ - **Doctor** uploads MRI and/or EEG, enters clinical-test scores (MMSE, MoCA, UPDRS, gait, age), and gets per-disease confidence with attribution.
8
+ - **Patient** sees a sanitised summary with lifestyle suggestions retrieved from peer-reviewed papers (RAG).
9
+ - **Researcher** sees a BBB-permeability map derived from MRI plus a drug-dosing adjustment hint when BBB leakage is elevated.
10
+
11
+ **Why decomposed.** Six subsystems with weak coupling. Building as one plan would produce an unreviewable mega-PR. Each sub-plan below ends in working software you could demo on its own.
12
+
13
+ ---
14
+
15
+ ## Sub-projects
16
+
17
+ | # | Sub-plan file | Owner concern | Depends on | Demo on its own? |
18
+ |---|---|---|---|---|
19
+ | 1 | `2026-05-02-fusion-engine.md` | Multi-modal disease confidence | — | yes (curl/JSON) |
20
+ | 2 | `2026-05-03-clinical-test-weighting.md` *(spec only)* | Doctor's clinical-test inputs + preset weights | 1 | yes (Streamlit form) |
21
+ | 3 | `2026-05-03-bbb-from-mri.md` *(spec only)* | DCE-MRI → BBB permeability map | parallel to 1 | yes (heatmap PNG) |
22
+ | 4 | `2026-05-04-persona-ui-gating.md` *(spec only)* | Doctor / Patient / Researcher views | 1, 2 | yes |
23
+ | 5 | `2026-05-04-lifestyle-rag.md` *(spec only)* | Patient lifestyle suggestions via RAG | 1 | yes |
24
+ | 6 | `2026-05-05-drug-dosing-adjustment.md` *(spec only)* | BBB leakage → drug concentration hint | 3 | yes |
25
+
26
+ ---
27
+
28
+ ## Sequencing
29
+
30
+ ```
31
+ ┌───────────────────────┐ ┌──────────────────────┐
32
+ │ 1. Fusion Engine │ │ 3. BBB-from-MRI │
33
+ │ (foundation) │ │ (independent) │
34
+ └─────────┬─────────────┘ └──────────┬───────────┘
35
+ │ │
36
+ ┌─────────▼──────────┐ │
37
+ │ 2. Clinical-test │ │
38
+ │ weighting UI │ │
39
+ └─────────┬──────────┘ │
40
+ │ │
41
+ ┌─────────▼──────────┐ │
42
+ │ 4. Persona UI │ │
43
+ │ gating │ │
44
+ └─────────┬──────────┘ │
45
+ │ │
46
+ ┌─────────▼──────────┐ ┌────────────▼────────────┐
47
+ │ 5. Lifestyle RAG │ │ 6. Drug-dosing │
48
+ │ (patient) │ │ adjustment (researcher)│
49
+ └────────────────────┘ └─────────────────────────┘
50
+ ```
51
+
52
+ Build order: **1 → 2 → 4** (doctor demo) and in parallel **3 → 6** (researcher demo). Then **5** (patient demo).
53
+
54
+ ---
55
+
56
+ ## Independence guarantees (non-negotiable)
57
+
58
+ The pipelines must stay decoupled. Even though they share a backend, no sub-plan may introduce a hard dependency between BBB and MRI (or any other pair). Concretely:
59
+
60
+ - **`bbb_pipeline` runs on a SMILES CSV alone.** It must never require MRI input or DCE-MRI data. A drug researcher with no patient images can use BBB end-to-end.
61
+ - **`mri_pipeline` runs on a NIfTI directory + sites CSV alone.** It must never require SMILES, BBB output, or DCE-MRI. A doctor with structural T1/T2 MRI only can use MRI end-to-end.
62
+ - **`eeg_pipeline` runs on a FIF/EDF file alone.** No MRI / BBB / DCE coupling.
63
+ - **`fusion` consumes whichever modality predictions exist.** It treats absence as "no signal" (renormalises onto provided weights only — see fusion sub-plan §"Renormalisation rule"). It does **not** call BBB.
64
+ - **Sub-plan #3 (BBB-from-MRI) is the *only* place BBB and MRI touch.** That bridge requires a DCE-MRI sequence specifically. When DCE-MRI is absent, sub-plan #3 is a no-op — the standard MRI flow and the standard SMILES BBB flow both continue to work independently.
65
+
66
+ **Test discipline.** Every sub-plan that adds a new module ships at least one test that runs the touched pipeline with the *other* pipelines fully unavailable (e.g. uninstall-style: import only what's needed, assert the path completes). The roadmap-level smoke test in sub-plan #1 Task 8 already covers fusion-without-BBB; sub-plan #3 must add the symmetric "MRI without DCE" and "BBB without MRI" paths.
67
+
68
+ **Why this matters.** Real clinical reality: most patients will only have one modality. A platform that silently fails or produces nonsense when modalities are missing is unusable. Decoupling now also keeps the demo flexible — we can show any single persona without setting up data for all of them.
69
+
70
+ ---
71
+
72
+ ## Architectural conventions (apply to every sub-plan)
73
+
74
+ These are already in `AGENTS.md`. Stated here so each sub-plan can refer back.
75
+
76
+ - **Logging.** Use `src.core.logger.get_logger(__name__)`. All loggers have `propagate=False`, so tests must attach `caplog.handler` directly. See `tests/llm/test_explainer.py` for the canonical pattern.
77
+ - **Pydantic v2.** Any model with a `model_*` field needs `model_config = ConfigDict(protected_namespaces=())`. See `src/api/schemas.py:77`.
78
+ - **Schemas.** All API request/response models live in `src/api/schemas.py`. Keep them grouped by feature with a section comment.
79
+ - **Agent tools.** New tools register in `src/agents/tools.py`. Each tool has a pydantic input/output and a pure `execute` callable.
80
+ - **TDD.** Each task: failing test → minimal impl → passing test → commit.
81
+ - **Conventional commits.** `feat(fusion): …`, `fix(api): …`, `test(fusion): …`, `docs(plan): …`.
82
+ - **No silent failures.** When a piece of input is missing or malformed, log + exclude rather than fabricate.
83
+
84
+ ---
85
+
86
+ ## Out of scope (explicitly)
87
+
88
+ Do not let any sub-plan smuggle these in:
89
+
90
+ - HIPAA-grade auth or PHI storage
91
+ - Multi-tenant patient records / EMR integration
92
+ - Real DCE-MRI training pipeline (we use a stub-able ONNX contract — same pattern as `src/models/mri_model.py`)
93
+ - FDA / clinical validation framing
94
+ - Anything that requires real labelled patient data we do not already have
95
+
96
+ The platform is a hackathon decision-support **demo**, not a regulated medical device.
97
+
98
+ ---
99
+
100
+ ## "When am I done?" gates
101
+
102
+ A sub-plan is complete when:
103
+
104
+ 1. All TDD tasks are committed.
105
+ 2. Full test suite passes locally (`pytest -q`).
106
+ 3. The feature is reachable end-to-end from the Streamlit UI **OR** documented in the plan as headless-only.
107
+ 4. A short demo paragraph is added to `README.md` (or a feature-specific section) describing the persona path.
108
+ 5. Final code-reviewer subagent verdict is "Ready to merge".
docs/superpowers/plans/2026-05-02-fusion-engine.md ADDED
@@ -0,0 +1,1231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fusion Engine Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Build a fusion module that takes any combination of MRI prediction, EEG prediction, and clinical-test scores and returns per-disease (Alzheimer's, Parkinson's, other) confidence with full attribution showing which input contributed which fraction.
6
+
7
+ **Architecture.** Pure Python module under `src/fusion/`. Each modality is converted to a signed signal in `[-1, 1]`. Per disease we compute `logit = bias + Σ weight × signal`, apply a sigmoid, then expose every term as a `ModalityContribution` so the UI can render an attribution bar. A FastAPI route and an agent tool sit on top so the orchestrator can call it. **No new ML training** — all models that feed it are existing artefacts.
8
+
9
+ **Tech stack.** Python 3.11, pydantic v2, FastAPI, pytest, numpy. Re-uses `src.core.logger`, `src.api.schemas`, `src.agents.tools` patterns already in the repo.
10
+
11
+ **Independence guarantee (locked in).** Fusion modalities are exactly: `mri`, `eeg`, and the named clinical scores (`mmse`, `moca`, `updrs`, `gait`, `age`). **BBB is NOT a fusion modality.** Even if a patient has BBB data, it does not flow into Alzheimer's/Parkinson's confidence — BBB belongs to the drug-researcher persona and lives in its own pipeline. The engine must never import from `src.pipelines.bbb_pipeline` or `src.models.bbb_model`. Task 5's tests include a regression assertion to enforce this.
12
+
13
+ **Independence test (Task 5).** A unit test imports `src.fusion.engine` and asserts `bbb` does not appear in any weight key, contribution modality, or imported module name. This pins the decoupling at CI time.
14
+
15
+ ---
16
+
17
+ ## File structure
18
+
19
+ | Path | Responsibility |
20
+ |---|---|
21
+ | Create `src/fusion/__init__.py` | package marker |
22
+ | Create `src/fusion/types.py` | pydantic types: `ModalityPrediction`, `ClinicalScores`, `FusionInput`, `ModalityContribution`, `DiseaseScore`, `FusionOutput` |
23
+ | Create `src/fusion/weights.py` | `DEFAULT_WEIGHTS`, `available_diseases()`, `available_clinical_tests()`, `get_weights(disease)` |
24
+ | Create `src/fusion/clinical.py` | per-test signal normalisers (`mmse_to_signal` etc.) |
25
+ | Create `src/fusion/modality.py` | `mri_signal_for_disease`, `eeg_signal_for_disease` |
26
+ | Create `src/fusion/engine.py` | `fuse(input) -> FusionOutput` |
27
+ | Modify `src/api/schemas.py` | add `FusionRequest`, `FusionResponse` (thin wrappers re-exporting fusion types) |
28
+ | Modify `src/api/routes.py` | mount `POST /fusion/predict` |
29
+ | Modify `src/agents/tools.py` | register `run_fusion` tool |
30
+ | Modify `src/agents/prompts.py` | add fusion tool description so the orchestrator can call it |
31
+ | Create `tests/fusion/__init__.py` | test package marker |
32
+ | Create `tests/fusion/test_weights.py` | weight registry tests |
33
+ | Create `tests/fusion/test_clinical.py` | normaliser boundary tests |
34
+ | Create `tests/fusion/test_modality.py` | modality signal extraction tests |
35
+ | Create `tests/fusion/test_engine.py` | core fusion behaviour tests |
36
+ | Create `tests/api/test_fusion_route.py` | FastAPI integration test |
37
+ | Create `tests/agents/test_tools_fusion.py` | agent tool wrapper test |
38
+
39
+ Each file is small and focused. No file is doing two jobs.
40
+
41
+ ---
42
+
43
+ ## Data contract (lock this in before coding)
44
+
45
+ ### Inputs
46
+
47
+ ```python
48
+ ModalityPrediction = {
49
+ "label_text": str, # e.g. "alzheimers", "parkinsons", "control"
50
+ "label": int, # class index from underlying model
51
+ "confidence": float, # in [0, 1]
52
+ "probabilities": [ # full softmax
53
+ {"label_text": str, "probability": float},
54
+ ...
55
+ ],
56
+ }
57
+
58
+ ClinicalScores = {
59
+ "mmse": float | None, # 0..30, lower = worse
60
+ "moca": float | None, # 0..30, lower = worse
61
+ "updrs": float | None, # 0..199, higher = worse
62
+ "gait_speed_m_s": float | None, # 0..2, lower = worse
63
+ "age_years": float | None, # 0..120
64
+ }
65
+ ```
66
+
67
+ ### Output
68
+
69
+ ```python
70
+ FusionOutput = {
71
+ "diseases": [
72
+ {
73
+ "disease": "alzheimers",
74
+ "probability": 0.71,
75
+ "contributions": [
76
+ {"modality": "mri", "weight": 0.40, "signal": 0.6, "delta_logit": 0.24},
77
+ {"modality": "clinical_mmse", "weight": 0.20, "signal": 0.8, "delta_logit": 0.16},
78
+ ...
79
+ ],
80
+ },
81
+ ...
82
+ ],
83
+ "top_disease": "alzheimers",
84
+ "missing_inputs": ["eeg"], # things that would have helped but were absent
85
+ }
86
+ ```
87
+
88
+ **Math.** For disease *d*: `logit_d = bias_d + Σ_m w_{m,d} · signal_{m,d}` where `signal_{m,d} ∈ [-1, 1]` and `Σ_m w_{m,d} = 1` (excluding modalities that were not provided — we renormalise on-the-fly). `probability = sigmoid(scale · logit_d)` with `scale = 4.0` (chosen so a single-modality saturated agreement maps to ~0.88, leaving headroom for stacking).
89
+
90
+ **bias_d.** Default `0.0` for every disease; configurable via weights file later.
91
+
92
+ ### Renormalisation rule
93
+
94
+ When some modalities are missing (e.g., EEG not uploaded), we normalise the **provided** weights to sum to the original sum of provided weights only — we do **not** redistribute missing weight onto remaining modalities, because that would silently inflate confidence. Concretely: if `mri` has weight 0.40 and `eeg` (weight 0.25) is missing, the disease's max attainable logit is now `0.40 · 1.0 + Σ clinical · 1.0` rather than `(0.40+0.25) · 1.0`. This naturally lowers confidence when modalities are absent — desired behaviour.
95
+
96
+ ---
97
+
98
+ ## Weights table (preset)
99
+
100
+ ```python
101
+ DEFAULT_WEIGHTS = {
102
+ "alzheimers": {
103
+ "mri": 0.35,
104
+ "eeg": 0.20,
105
+ "clinical_mmse": 0.20,
106
+ "clinical_moca": 0.15,
107
+ "clinical_age": 0.10,
108
+ },
109
+ "parkinsons": {
110
+ "mri": 0.20,
111
+ "eeg": 0.30,
112
+ "clinical_updrs": 0.30,
113
+ "clinical_gait": 0.15,
114
+ "clinical_age": 0.05,
115
+ },
116
+ "other": {
117
+ "mri": 0.50,
118
+ "eeg": 0.50,
119
+ },
120
+ }
121
+ ```
122
+
123
+ These are heuristic, not validated. The plan exposes them in code so a clinician collaborator can tune them without a deploy.
124
+
125
+ ---
126
+
127
+ ## Tasks
128
+
129
+ ### Task 1: Types and schemas
130
+
131
+ **Files:**
132
+ - Create: `src/fusion/__init__.py`
133
+ - Create: `src/fusion/types.py`
134
+ - Create: `tests/fusion/__init__.py`
135
+ - Test: `tests/fusion/test_types.py`
136
+
137
+ - [ ] **Step 1: Write the failing test**
138
+
139
+ Create `tests/fusion/test_types.py`:
140
+
141
+ ```python
142
+ """Tests for src.fusion.types — pydantic contract for fusion I/O."""
143
+ from __future__ import annotations
144
+
145
+ import pytest
146
+ from pydantic import ValidationError
147
+
148
+ from src.fusion.types import (
149
+ ClinicalScores,
150
+ DiseaseScore,
151
+ FusionInput,
152
+ FusionOutput,
153
+ ModalityContribution,
154
+ ModalityPrediction,
155
+ )
156
+
157
+
158
+ class TestModalityPrediction:
159
+ def test_minimal_round_trip(self) -> None:
160
+ pred = ModalityPrediction(
161
+ label_text="alzheimers", label=1, confidence=0.81,
162
+ probabilities=[
163
+ {"label_text": "control", "probability": 0.19},
164
+ {"label_text": "alzheimers", "probability": 0.81},
165
+ ],
166
+ )
167
+ assert pred.label == 1
168
+ assert pred.probabilities[1].probability == pytest.approx(0.81)
169
+
170
+ def test_probabilities_must_be_non_empty(self) -> None:
171
+ with pytest.raises(ValidationError):
172
+ ModalityPrediction(label_text="x", label=0, confidence=0.5, probabilities=[])
173
+
174
+
175
+ class TestClinicalScores:
176
+ def test_all_optional(self) -> None:
177
+ s = ClinicalScores()
178
+ assert s.mmse is None and s.age_years is None
179
+
180
+ def test_rejects_out_of_range_mmse(self) -> None:
181
+ with pytest.raises(ValidationError):
182
+ ClinicalScores(mmse=42.0)
183
+
184
+
185
+ class TestFusionInputOutput:
186
+ def test_fusion_input_allows_no_modalities(self) -> None:
187
+ # Caller may pass nothing — engine returns baseline scores.
188
+ f = FusionInput()
189
+ assert f.mri is None and f.eeg is None
190
+ assert f.clinical == ClinicalScores()
191
+
192
+ def test_fusion_output_round_trip(self) -> None:
193
+ out = FusionOutput(
194
+ diseases=[
195
+ DiseaseScore(
196
+ disease="alzheimers",
197
+ probability=0.7,
198
+ contributions=[
199
+ ModalityContribution(
200
+ modality="mri", weight=0.35, signal=0.6, delta_logit=0.21,
201
+ )
202
+ ],
203
+ )
204
+ ],
205
+ top_disease="alzheimers",
206
+ missing_inputs=["eeg"],
207
+ )
208
+ assert out.top_disease == "alzheimers"
209
+ assert out.diseases[0].contributions[0].delta_logit == pytest.approx(0.21)
210
+ ```
211
+
212
+ - [ ] **Step 2: Run test to verify it fails**
213
+
214
+ Run: `pytest tests/fusion/test_types.py -v`
215
+ Expected: FAIL — `ModuleNotFoundError: No module named 'src.fusion'`
216
+
217
+ - [ ] **Step 3: Write minimal implementation**
218
+
219
+ Create `src/fusion/__init__.py` (empty file).
220
+
221
+ Create `src/fusion/types.py`:
222
+
223
+ ```python
224
+ """Pydantic data contract for the multi-modal fusion engine."""
225
+ from __future__ import annotations
226
+
227
+ from typing import Annotated
228
+
229
+ from pydantic import BaseModel, ConfigDict, Field
230
+
231
+
232
+ class ModalityClassProb(BaseModel):
233
+ label_text: str
234
+ probability: float = Field(..., ge=0.0, le=1.0)
235
+
236
+
237
+ class ModalityPrediction(BaseModel):
238
+ """One modality's classifier output (MRI or EEG)."""
239
+ model_config = ConfigDict(protected_namespaces=())
240
+
241
+ label_text: str
242
+ label: int = Field(..., ge=0)
243
+ confidence: float = Field(..., ge=0.0, le=1.0)
244
+ probabilities: list[ModalityClassProb] = Field(..., min_length=1)
245
+
246
+
247
+ class ClinicalScores(BaseModel):
248
+ """Doctor-entered extra-test scores. Each is optional."""
249
+ mmse: Annotated[float, Field(ge=0.0, le=30.0)] | None = None
250
+ moca: Annotated[float, Field(ge=0.0, le=30.0)] | None = None
251
+ updrs: Annotated[float, Field(ge=0.0, le=199.0)] | None = None
252
+ gait_speed_m_s: Annotated[float, Field(ge=0.0, le=2.5)] | None = None
253
+ age_years: Annotated[float, Field(ge=0.0, le=120.0)] | None = None
254
+
255
+
256
+ class FusionInput(BaseModel):
257
+ mri: ModalityPrediction | None = None
258
+ eeg: ModalityPrediction | None = None
259
+ clinical: ClinicalScores = Field(default_factory=ClinicalScores)
260
+
261
+
262
+ class ModalityContribution(BaseModel):
263
+ """One row of the attribution table for a single disease score."""
264
+ modality: str # "mri" | "eeg" | "clinical_<name>"
265
+ weight: float
266
+ signal: float = Field(..., ge=-1.0, le=1.0)
267
+ delta_logit: float
268
+
269
+
270
+ class DiseaseScore(BaseModel):
271
+ disease: str
272
+ probability: float = Field(..., ge=0.0, le=1.0)
273
+ contributions: list[ModalityContribution]
274
+
275
+
276
+ class FusionOutput(BaseModel):
277
+ diseases: list[DiseaseScore]
278
+ top_disease: str
279
+ missing_inputs: list[str] = Field(default_factory=list)
280
+ ```
281
+
282
+ - [ ] **Step 4: Run test to verify it passes**
283
+
284
+ Run: `pytest tests/fusion/test_types.py -v`
285
+ Expected: PASS (5 tests)
286
+
287
+ - [ ] **Step 5: Commit**
288
+
289
+ ```bash
290
+ git add src/fusion/__init__.py src/fusion/types.py tests/fusion/__init__.py tests/fusion/test_types.py
291
+ git commit -m "feat(fusion): add pydantic data contract for multi-modal fusion"
292
+ ```
293
+
294
+ ---
295
+
296
+ ### Task 2: Weight registry
297
+
298
+ **Files:**
299
+ - Create: `src/fusion/weights.py`
300
+ - Test: `tests/fusion/test_weights.py`
301
+
302
+ - [ ] **Step 1: Write the failing test**
303
+
304
+ Create `tests/fusion/test_weights.py`:
305
+
306
+ ```python
307
+ """Tests for src.fusion.weights — disease/modality weight registry."""
308
+ from __future__ import annotations
309
+
310
+ import pytest
311
+
312
+ from src.fusion import weights
313
+
314
+
315
+ class TestWeights:
316
+ def test_available_diseases_includes_known(self) -> None:
317
+ diseases = set(weights.available_diseases())
318
+ assert {"alzheimers", "parkinsons", "other"} <= diseases
319
+
320
+ def test_available_clinical_tests_includes_each_named_input(self) -> None:
321
+ # Every clinical_<name> weight key must correspond to a clinical input.
322
+ tests = set(weights.available_clinical_tests())
323
+ assert {"mmse", "moca", "updrs", "gait", "age"} <= tests
324
+
325
+ def test_get_weights_returns_nonempty_mapping(self) -> None:
326
+ ws = weights.get_weights("alzheimers")
327
+ assert ws["mri"] > 0
328
+ assert sum(ws.values()) == pytest.approx(1.0, abs=1e-6)
329
+
330
+ def test_get_weights_unknown_disease_raises(self) -> None:
331
+ with pytest.raises(KeyError, match="unknown disease"):
332
+ weights.get_weights("invented_disease")
333
+
334
+ def test_each_disease_weight_table_sums_to_one(self) -> None:
335
+ for d in weights.available_diseases():
336
+ assert sum(weights.get_weights(d).values()) == pytest.approx(1.0, abs=1e-6), d
337
+ ```
338
+
339
+ - [ ] **Step 2: Run test to verify it fails**
340
+
341
+ Run: `pytest tests/fusion/test_weights.py -v`
342
+ Expected: FAIL — `ModuleNotFoundError: No module named 'src.fusion.weights'`
343
+
344
+ - [ ] **Step 3: Write minimal implementation**
345
+
346
+ Create `src/fusion/weights.py`:
347
+
348
+ ```python
349
+ """Disease × modality weight registry for the fusion engine.
350
+
351
+ Heuristic preset weights — tune offline as clinician feedback arrives.
352
+ """
353
+ from __future__ import annotations
354
+
355
+ from typing import Mapping
356
+
357
+ DEFAULT_WEIGHTS: dict[str, dict[str, float]] = {
358
+ "alzheimers": {
359
+ "mri": 0.35,
360
+ "eeg": 0.20,
361
+ "clinical_mmse": 0.20,
362
+ "clinical_moca": 0.15,
363
+ "clinical_age": 0.10,
364
+ },
365
+ "parkinsons": {
366
+ "mri": 0.20,
367
+ "eeg": 0.30,
368
+ "clinical_updrs": 0.30,
369
+ "clinical_gait": 0.15,
370
+ "clinical_age": 0.05,
371
+ },
372
+ "other": {
373
+ "mri": 0.50,
374
+ "eeg": 0.50,
375
+ },
376
+ }
377
+
378
+
379
+ def available_diseases() -> list[str]:
380
+ return sorted(DEFAULT_WEIGHTS.keys())
381
+
382
+
383
+ def available_clinical_tests() -> list[str]:
384
+ """Return the bare clinical-test names (without the 'clinical_' prefix)."""
385
+ names: set[str] = set()
386
+ for table in DEFAULT_WEIGHTS.values():
387
+ for key in table:
388
+ if key.startswith("clinical_"):
389
+ names.add(key[len("clinical_"):])
390
+ return sorted(names)
391
+
392
+
393
+ def get_weights(disease: str) -> Mapping[str, float]:
394
+ if disease not in DEFAULT_WEIGHTS:
395
+ raise KeyError(f"unknown disease: {disease!r}")
396
+ return DEFAULT_WEIGHTS[disease]
397
+ ```
398
+
399
+ - [ ] **Step 4: Run test to verify it passes**
400
+
401
+ Run: `pytest tests/fusion/test_weights.py -v`
402
+ Expected: PASS (5 tests)
403
+
404
+ - [ ] **Step 5: Commit**
405
+
406
+ ```bash
407
+ git add src/fusion/weights.py tests/fusion/test_weights.py
408
+ git commit -m "feat(fusion): add disease/modality weight registry"
409
+ ```
410
+
411
+ ---
412
+
413
+ ### Task 3: Clinical signal normalisers
414
+
415
+ **Files:**
416
+ - Create: `src/fusion/clinical.py`
417
+ - Test: `tests/fusion/test_clinical.py`
418
+
419
+ - [ ] **Step 1: Write the failing test**
420
+
421
+ Create `tests/fusion/test_clinical.py`:
422
+
423
+ ```python
424
+ """Tests for src.fusion.clinical — per-test signal normalisers.
425
+
426
+ Convention: signal in [-1, 1] where +1 = strong evidence the disease IS
427
+ present and -1 = strong evidence it is NOT present.
428
+ """
429
+ from __future__ import annotations
430
+
431
+ import pytest
432
+
433
+ from src.fusion import clinical
434
+
435
+
436
+ class TestMMSE:
437
+ def test_perfect_score_signals_no_alzheimers(self) -> None:
438
+ assert clinical.mmse_to_signal(30.0) == pytest.approx(-1.0)
439
+
440
+ def test_severely_impaired_signals_alzheimers(self) -> None:
441
+ assert clinical.mmse_to_signal(0.0) == pytest.approx(1.0)
442
+
443
+ def test_borderline_24_is_near_neutral_slightly_positive(self) -> None:
444
+ # MMSE 24 is the mild-impairment cutoff. Signal should be slightly above 0.
445
+ sig = clinical.mmse_to_signal(24.0)
446
+ assert -0.1 < sig < 0.5
447
+
448
+
449
+ class TestMoCA:
450
+ def test_perfect_signals_negative(self) -> None:
451
+ assert clinical.moca_to_signal(30.0) == pytest.approx(-1.0)
452
+
453
+ def test_zero_signals_positive(self) -> None:
454
+ assert clinical.moca_to_signal(0.0) == pytest.approx(1.0)
455
+
456
+
457
+ class TestUPDRS:
458
+ def test_zero_signals_no_parkinsons(self) -> None:
459
+ assert clinical.updrs_to_signal(0.0) == pytest.approx(-1.0)
460
+
461
+ def test_max_signals_parkinsons(self) -> None:
462
+ assert clinical.updrs_to_signal(199.0) == pytest.approx(1.0, abs=1e-3)
463
+
464
+
465
+ class TestGait:
466
+ def test_fast_walker_signals_negative(self) -> None:
467
+ # Healthy adult gait ~1.4 m/s — signal should be clearly negative.
468
+ assert clinical.gait_to_signal(1.4) < -0.4
469
+
470
+ def test_slow_walker_signals_positive(self) -> None:
471
+ # Bradykinesia / shuffling gait < 0.5 m/s — signal should be positive.
472
+ assert clinical.gait_to_signal(0.3) > 0.4
473
+
474
+
475
+ class TestAge:
476
+ def test_young_signals_negative(self) -> None:
477
+ assert clinical.age_to_signal(30.0) < -0.4
478
+
479
+ def test_elderly_signals_positive(self) -> None:
480
+ assert clinical.age_to_signal(85.0) > 0.4
481
+ ```
482
+
483
+ - [ ] **Step 2: Run test to verify it fails**
484
+
485
+ Run: `pytest tests/fusion/test_clinical.py -v`
486
+ Expected: FAIL — `ModuleNotFoundError`.
487
+
488
+ - [ ] **Step 3: Write minimal implementation**
489
+
490
+ Create `src/fusion/clinical.py`:
491
+
492
+ ```python
493
+ """Map raw clinical-test scores to a unitless signal in [-1, 1].
494
+
495
+ +1 means the test strongly supports the disease being present.
496
+ -1 means it strongly supports the disease being absent.
497
+ """
498
+ from __future__ import annotations
499
+
500
+
501
+ def _linear_map(value: float, low: float, high: float, *, invert: bool) -> float:
502
+ """Map `value` from [low, high] to [-1, 1]. If invert, flip sign."""
503
+ if high == low:
504
+ return 0.0
505
+ clipped = max(low, min(high, value))
506
+ norm = (clipped - low) / (high - low) # [0, 1]
507
+ signal = 2.0 * norm - 1.0 # [-1, 1]
508
+ return -signal if invert else signal
509
+
510
+
511
+ def mmse_to_signal(score: float) -> float:
512
+ # MMSE: 30 = healthy, 0 = severe — invert so low score => +1.
513
+ return _linear_map(score, low=0.0, high=30.0, invert=True)
514
+
515
+
516
+ def moca_to_signal(score: float) -> float:
517
+ return _linear_map(score, low=0.0, high=30.0, invert=True)
518
+
519
+
520
+ def updrs_to_signal(score: float) -> float:
521
+ # UPDRS: 0 = healthy, ~199 = severe.
522
+ return _linear_map(score, low=0.0, high=199.0, invert=False)
523
+
524
+
525
+ def gait_to_signal(speed_m_s: float) -> float:
526
+ # Healthy adult ~1.4 m/s, parkinsonian shuffling < 0.5 m/s.
527
+ return _linear_map(speed_m_s, low=0.0, high=1.4, invert=True)
528
+
529
+
530
+ def age_to_signal(years: float) -> float:
531
+ # Risk rises sharply past 65. Anchor: 30 -> -1, 90 -> +1.
532
+ return _linear_map(years, low=30.0, high=90.0, invert=False)
533
+ ```
534
+
535
+ - [ ] **Step 4: Run test to verify it passes**
536
+
537
+ Run: `pytest tests/fusion/test_clinical.py -v`
538
+ Expected: PASS (10 tests)
539
+
540
+ - [ ] **Step 5: Commit**
541
+
542
+ ```bash
543
+ git add src/fusion/clinical.py tests/fusion/test_clinical.py
544
+ git commit -m "feat(fusion): add clinical-test signal normalisers (MMSE/MoCA/UPDRS/gait/age)"
545
+ ```
546
+
547
+ ---
548
+
549
+ ### Task 4: Modality signal extractors
550
+
551
+ **Files:**
552
+ - Create: `src/fusion/modality.py`
553
+ - Test: `tests/fusion/test_modality.py`
554
+
555
+ - [ ] **Step 1: Write the failing test**
556
+
557
+ Create `tests/fusion/test_modality.py`:
558
+
559
+ ```python
560
+ """Tests for src.fusion.modality — turn ModalityPrediction into a per-disease signal."""
561
+ from __future__ import annotations
562
+
563
+ import pytest
564
+
565
+ from src.fusion.modality import signal_for_disease
566
+ from src.fusion.types import ModalityClassProb, ModalityPrediction
567
+
568
+
569
+ def _pred(probs: dict[str, float]) -> ModalityPrediction:
570
+ items = [ModalityClassProb(label_text=k, probability=v) for k, v in probs.items()]
571
+ top = max(items, key=lambda p: p.probability)
572
+ return ModalityPrediction(
573
+ label_text=top.label_text,
574
+ label=list(probs).index(top.label_text),
575
+ confidence=top.probability,
576
+ probabilities=items,
577
+ )
578
+
579
+
580
+ class TestSignalForDisease:
581
+ def test_disease_class_present_high_prob(self) -> None:
582
+ # The model exposes a class for the disease and assigns it 0.9.
583
+ pred = _pred({"control": 0.1, "alzheimers": 0.9})
584
+ sig = signal_for_disease(pred, disease="alzheimers")
585
+ assert sig == pytest.approx(0.8) # 2*0.9 - 1
586
+
587
+ def test_disease_class_present_low_prob(self) -> None:
588
+ pred = _pred({"control": 0.95, "alzheimers": 0.05})
589
+ sig = signal_for_disease(pred, disease="alzheimers")
590
+ assert sig == pytest.approx(-0.9)
591
+
592
+ def test_disease_class_absent_returns_none(self) -> None:
593
+ # Model only emits {"control", "parkinsons"}; we ask for alzheimers.
594
+ pred = _pred({"control": 0.4, "parkinsons": 0.6})
595
+ sig = signal_for_disease(pred, disease="alzheimers")
596
+ assert sig is None
597
+
598
+ def test_label_alias_matches_case_insensitively(self) -> None:
599
+ pred = _pred({"Control": 0.2, "ALZHEIMERS": 0.8})
600
+ sig = signal_for_disease(pred, disease="alzheimers")
601
+ assert sig == pytest.approx(0.6)
602
+ ```
603
+
604
+ - [ ] **Step 2: Run test to verify it fails**
605
+
606
+ Run: `pytest tests/fusion/test_modality.py -v`
607
+ Expected: FAIL — module missing.
608
+
609
+ - [ ] **Step 3: Write minimal implementation**
610
+
611
+ Create `src/fusion/modality.py`:
612
+
613
+ ```python
614
+ """Convert a modality classifier's probability vector into a signed signal."""
615
+ from __future__ import annotations
616
+
617
+ from src.fusion.types import ModalityPrediction
618
+
619
+
620
+ def signal_for_disease(pred: ModalityPrediction, disease: str) -> float | None:
621
+ """Return signal in [-1, 1] for `disease`, or None if the model has no
622
+ matching class.
623
+
624
+ A class matches if its `label_text` equals `disease` case-insensitively.
625
+ Signal = 2 * P(disease) - 1.
626
+ """
627
+ target = disease.strip().lower()
628
+ for cls in pred.probabilities:
629
+ if cls.label_text.strip().lower() == target:
630
+ return 2.0 * cls.probability - 1.0
631
+ return None
632
+ ```
633
+
634
+ - [ ] **Step 4: Run test to verify it passes**
635
+
636
+ Run: `pytest tests/fusion/test_modality.py -v`
637
+ Expected: PASS (4 tests)
638
+
639
+ - [ ] **Step 5: Commit**
640
+
641
+ ```bash
642
+ git add src/fusion/modality.py tests/fusion/test_modality.py
643
+ git commit -m "feat(fusion): map modality predictions to per-disease signals"
644
+ ```
645
+
646
+ ---
647
+
648
+ ### Task 5: Fusion engine core
649
+
650
+ **Files:**
651
+ - Create: `src/fusion/engine.py`
652
+ - Test: `tests/fusion/test_engine.py`
653
+
654
+ - [ ] **Step 1: Write the failing test**
655
+
656
+ Create `tests/fusion/test_engine.py`:
657
+
658
+ ```python
659
+ """Tests for src.fusion.engine.fuse — the core multi-modal combiner."""
660
+ from __future__ import annotations
661
+
662
+ import logging
663
+ from typing import Any
664
+
665
+ import pytest
666
+
667
+ from src.fusion import engine
668
+ from src.fusion.types import (
669
+ ClinicalScores,
670
+ FusionInput,
671
+ ModalityClassProb,
672
+ ModalityPrediction,
673
+ )
674
+
675
+
676
+ def _mri(prob_alz: float, prob_pd: float = 0.0) -> ModalityPrediction:
677
+ p_other = max(0.0, 1.0 - prob_alz - prob_pd)
678
+ items = [
679
+ ModalityClassProb(label_text="control", probability=p_other),
680
+ ModalityClassProb(label_text="alzheimers", probability=prob_alz),
681
+ ModalityClassProb(label_text="parkinsons", probability=prob_pd),
682
+ ]
683
+ top = max(items, key=lambda p: p.probability)
684
+ return ModalityPrediction(
685
+ label_text=top.label_text,
686
+ label=[p.label_text for p in items].index(top.label_text),
687
+ confidence=top.probability,
688
+ probabilities=items,
689
+ )
690
+
691
+
692
+ class TestFuse:
693
+ def test_empty_input_returns_baseline_with_missing_listed(self) -> None:
694
+ out = engine.fuse(FusionInput())
695
+ assert {d.disease for d in out.diseases} >= {"alzheimers", "parkinsons", "other"}
696
+ for ds in out.diseases:
697
+ assert ds.probability == pytest.approx(0.5, abs=1e-6)
698
+ assert ds.contributions == []
699
+ assert "mri" in out.missing_inputs
700
+ assert "eeg" in out.missing_inputs
701
+
702
+ def test_mri_only_alzheimers_high(self) -> None:
703
+ inp = FusionInput(mri=_mri(prob_alz=0.9))
704
+ out = engine.fuse(inp)
705
+ alz = next(d for d in out.diseases if d.disease == "alzheimers")
706
+ assert alz.probability > 0.7
707
+ assert any(c.modality == "mri" for c in alz.contributions)
708
+ assert out.top_disease == "alzheimers"
709
+
710
+ def test_mri_eeg_agreement_boosts_above_either_alone(self) -> None:
711
+ only_mri = engine.fuse(FusionInput(mri=_mri(prob_alz=0.8)))
712
+ only_eeg = engine.fuse(FusionInput(eeg=_mri(prob_alz=0.8)))
713
+ both = engine.fuse(FusionInput(
714
+ mri=_mri(prob_alz=0.8), eeg=_mri(prob_alz=0.8),
715
+ ))
716
+
717
+ def alz(out: Any) -> float:
718
+ return next(d for d in out.diseases if d.disease == "alzheimers").probability
719
+
720
+ assert alz(both) > alz(only_mri)
721
+ assert alz(both) > alz(only_eeg)
722
+
723
+ def test_clinical_only_low_mmse_raises_alzheimers(self) -> None:
724
+ out = engine.fuse(FusionInput(clinical=ClinicalScores(mmse=10.0)))
725
+ alz = next(d for d in out.diseases if d.disease == "alzheimers")
726
+ assert alz.probability > 0.55
727
+ assert any(c.modality == "clinical_mmse" for c in alz.contributions)
728
+
729
+ def test_disagreement_moderates_confidence(self) -> None:
730
+ # MRI says alzheimers, clinical MMSE is perfect (against).
731
+ out = engine.fuse(FusionInput(
732
+ mri=_mri(prob_alz=0.85),
733
+ clinical=ClinicalScores(mmse=30.0),
734
+ ))
735
+ alz = next(d for d in out.diseases if d.disease == "alzheimers")
736
+ # Lower than MRI-only would have been (0.7+), but still elevated.
737
+ assert 0.5 < alz.probability < 0.78
738
+
739
+ def test_unknown_clinical_field_is_ignored_safely(self) -> None:
740
+ # If a clinical field isn't in any weight table, it's still valid input
741
+ # and must not error. (No such field exists in pydantic, but covers
742
+ # defensive paths for future fields.)
743
+ out = engine.fuse(FusionInput(clinical=ClinicalScores(age_years=80.0)))
744
+ assert out.top_disease in {"alzheimers", "parkinsons", "other"}
745
+
746
+ def test_engine_does_not_depend_on_bbb(self) -> None:
747
+ # Independence regression: fusion must not couple to BBB. A patient
748
+ # with only MRI/EEG/clinical data must produce a valid output even
749
+ # though no BBB module is involved.
750
+ import inspect
751
+ import src.fusion.engine as engine_mod
752
+ import src.fusion.weights as weights_mod
753
+ # No imports from bbb anywhere in the fusion package.
754
+ assert "bbb" not in inspect.getsource(engine_mod).lower()
755
+ # No 'bbb' weight key in any disease table.
756
+ for disease in weights_mod.available_diseases():
757
+ for key in weights_mod.get_weights(disease):
758
+ assert "bbb" not in key.lower(), (disease, key)
759
+
760
+ def test_warning_logged_when_disease_has_no_signals(
761
+ self, caplog: pytest.LogCaptureFixture
762
+ ) -> None:
763
+ # 'other' disease with no MRI/EEG inputs -> no signals available.
764
+ # Engine should log a debug/info note and produce baseline 0.5 for it.
765
+ engine.logger.addHandler(caplog.handler)
766
+ caplog.handler.setLevel(logging.INFO)
767
+ try:
768
+ out = engine.fuse(FusionInput(clinical=ClinicalScores(mmse=10.0)))
769
+ finally:
770
+ engine.logger.removeHandler(caplog.handler)
771
+ other = next(d for d in out.diseases if d.disease == "other")
772
+ assert other.probability == pytest.approx(0.5, abs=1e-6)
773
+ assert other.contributions == []
774
+ ```
775
+
776
+ - [ ] **Step 2: Run test to verify it fails**
777
+
778
+ Run: `pytest tests/fusion/test_engine.py -v`
779
+ Expected: FAIL — engine module missing.
780
+
781
+ - [ ] **Step 3: Write minimal implementation**
782
+
783
+ Create `src/fusion/engine.py`:
784
+
785
+ ```python
786
+ """Multi-modal fusion engine — combines MRI, EEG, and clinical signals into
787
+ per-disease confidence with full attribution.
788
+ """
789
+ from __future__ import annotations
790
+
791
+ import math
792
+ from typing import Callable
793
+
794
+ from src.core.logger import get_logger
795
+ from src.fusion import clinical as clinical_signals
796
+ from src.fusion import weights as weight_registry
797
+ from src.fusion.modality import signal_for_disease
798
+ from src.fusion.types import (
799
+ ClinicalScores,
800
+ DiseaseScore,
801
+ FusionInput,
802
+ FusionOutput,
803
+ ModalityContribution,
804
+ ModalityPrediction,
805
+ )
806
+
807
+ logger = get_logger(__name__)
808
+
809
+ _LOGIT_SCALE = 4.0 # tuned so a single saturated modality maps to ~0.88
810
+
811
+
812
+ # Clinical-test name -> (signal_fn, attribute_on_ClinicalScores)
813
+ _CLINICAL_FNS: dict[str, tuple[Callable[[float], float], str]] = {
814
+ "clinical_mmse": (clinical_signals.mmse_to_signal, "mmse"),
815
+ "clinical_moca": (clinical_signals.moca_to_signal, "moca"),
816
+ "clinical_updrs": (clinical_signals.updrs_to_signal, "updrs"),
817
+ "clinical_gait": (clinical_signals.gait_to_signal, "gait_speed_m_s"),
818
+ "clinical_age": (clinical_signals.age_to_signal, "age_years"),
819
+ }
820
+
821
+
822
+ def fuse(inp: FusionInput) -> FusionOutput:
823
+ """Combine all available modalities into a per-disease confidence."""
824
+ missing: list[str] = []
825
+ if inp.mri is None:
826
+ missing.append("mri")
827
+ if inp.eeg is None:
828
+ missing.append("eeg")
829
+
830
+ diseases: list[DiseaseScore] = []
831
+ for disease in weight_registry.available_diseases():
832
+ diseases.append(_score_one_disease(disease, inp))
833
+
834
+ top = max(diseases, key=lambda d: d.probability).disease
835
+ return FusionOutput(diseases=diseases, top_disease=top, missing_inputs=missing)
836
+
837
+
838
+ def _score_one_disease(disease: str, inp: FusionInput) -> DiseaseScore:
839
+ weights = weight_registry.get_weights(disease)
840
+ contributions: list[ModalityContribution] = []
841
+
842
+ for modality_key, weight in weights.items():
843
+ signal = _signal_for_modality(modality_key, disease, inp.mri, inp.eeg, inp.clinical)
844
+ if signal is None:
845
+ continue
846
+ contributions.append(ModalityContribution(
847
+ modality=modality_key,
848
+ weight=weight,
849
+ signal=signal,
850
+ delta_logit=weight * signal,
851
+ ))
852
+
853
+ if not contributions:
854
+ logger.info("no signals available for disease=%s; returning baseline 0.5", disease)
855
+ return DiseaseScore(disease=disease, probability=0.5, contributions=[])
856
+
857
+ logit = sum(c.delta_logit for c in contributions)
858
+ probability = _sigmoid(_LOGIT_SCALE * logit)
859
+ return DiseaseScore(
860
+ disease=disease,
861
+ probability=probability,
862
+ contributions=contributions,
863
+ )
864
+
865
+
866
+ def _signal_for_modality(
867
+ modality_key: str,
868
+ disease: str,
869
+ mri: ModalityPrediction | None,
870
+ eeg: ModalityPrediction | None,
871
+ clinical: ClinicalScores,
872
+ ) -> float | None:
873
+ if modality_key == "mri":
874
+ return signal_for_disease(mri, disease) if mri is not None else None
875
+ if modality_key == "eeg":
876
+ return signal_for_disease(eeg, disease) if eeg is not None else None
877
+ if modality_key in _CLINICAL_FNS:
878
+ fn, attr = _CLINICAL_FNS[modality_key]
879
+ value = getattr(clinical, attr, None)
880
+ return fn(value) if value is not None else None
881
+ logger.warning("unknown modality key in weights table: %s", modality_key)
882
+ return None
883
+
884
+
885
+ def _sigmoid(x: float) -> float:
886
+ if x >= 0:
887
+ z = math.exp(-x)
888
+ return 1.0 / (1.0 + z)
889
+ z = math.exp(x)
890
+ return z / (1.0 + z)
891
+ ```
892
+
893
+ - [ ] **Step 4: Run test to verify it passes**
894
+
895
+ Run: `pytest tests/fusion/test_engine.py -v`
896
+ Expected: PASS (8 tests, including the BBB-independence regression)
897
+
898
+ - [ ] **Step 5: Commit**
899
+
900
+ ```bash
901
+ git add src/fusion/engine.py tests/fusion/test_engine.py
902
+ git commit -m "feat(fusion): add core multi-modal fuse() with per-disease attribution"
903
+ ```
904
+
905
+ ---
906
+
907
+ ### Task 6: FastAPI route
908
+
909
+ **Files:**
910
+ - Modify: `src/api/schemas.py` (append fusion section)
911
+ - Modify: `src/api/routes.py` (add `/fusion/predict`)
912
+ - Test: `tests/api/test_fusion_route.py`
913
+
914
+ - [ ] **Step 1: Write the failing test**
915
+
916
+ Create `tests/api/test_fusion_route.py`:
917
+
918
+ ```python
919
+ """Integration test for POST /fusion/predict."""
920
+ from __future__ import annotations
921
+
922
+ from fastapi.testclient import TestClient
923
+
924
+ from src.api.main import app
925
+
926
+
927
+ client = TestClient(app)
928
+
929
+
930
+ class TestFusionRoute:
931
+ def test_happy_path_mri_only(self) -> None:
932
+ body = {
933
+ "mri": {
934
+ "label_text": "alzheimers",
935
+ "label": 1,
936
+ "confidence": 0.88,
937
+ "probabilities": [
938
+ {"label_text": "control", "probability": 0.12},
939
+ {"label_text": "alzheimers", "probability": 0.88},
940
+ ],
941
+ },
942
+ }
943
+ r = client.post("/fusion/predict", json=body)
944
+ assert r.status_code == 200, r.text
945
+ data = r.json()
946
+ assert "diseases" in data
947
+ assert any(d["disease"] == "alzheimers" for d in data["diseases"])
948
+ assert data["top_disease"] in {"alzheimers", "parkinsons", "other"}
949
+
950
+ def test_empty_input_returns_baseline(self) -> None:
951
+ r = client.post("/fusion/predict", json={})
952
+ assert r.status_code == 200
953
+ data = r.json()
954
+ for d in data["diseases"]:
955
+ assert abs(d["probability"] - 0.5) < 1e-6
956
+ assert "mri" in data["missing_inputs"]
957
+
958
+ def test_invalid_probability_returns_422(self) -> None:
959
+ body = {
960
+ "mri": {
961
+ "label_text": "x",
962
+ "label": 0,
963
+ "confidence": 1.5, # invalid
964
+ "probabilities": [{"label_text": "x", "probability": 1.5}],
965
+ },
966
+ }
967
+ r = client.post("/fusion/predict", json=body)
968
+ assert r.status_code == 422
969
+ ```
970
+
971
+ - [ ] **Step 2: Run test to verify it fails**
972
+
973
+ Run: `pytest tests/api/test_fusion_route.py -v`
974
+ Expected: FAIL — 404 (route missing).
975
+
976
+ - [ ] **Step 3: Wire schemas + route**
977
+
978
+ Append to `src/api/schemas.py` (at the bottom, before any closing matter):
979
+
980
+ ```python
981
+ # --- Fusion engine surface --------------------------------------------------
982
+
983
+ # Re-export the fusion types so the API surface lives in one file but the
984
+ # implementation stays in src/fusion. This keeps `from src.api.schemas import *`
985
+ # style imports stable for the frontend layer.
986
+ from src.fusion.types import ( # noqa: E402,F401
987
+ ClinicalScores as FusionClinicalScores,
988
+ FusionInput as FusionRequest,
989
+ FusionOutput as FusionResponse,
990
+ ModalityPrediction as FusionModalityPrediction,
991
+ )
992
+ ```
993
+
994
+ In `src/api/routes.py`, add the route. Find an existing pipeline route (e.g. `@router.post("/pipeline/bbb"...)`) and add this near the bottom of the same router:
995
+
996
+ ```python
997
+ from src.fusion.engine import fuse as fuse_engine
998
+ from src.api.schemas import FusionRequest, FusionResponse
999
+
1000
+
1001
+ @router.post("/fusion/predict", response_model=FusionResponse)
1002
+ def fusion_predict(req: FusionRequest) -> FusionResponse:
1003
+ """Combine MRI, EEG, and clinical scores into per-disease confidence."""
1004
+ return fuse_engine(req)
1005
+ ```
1006
+
1007
+ - [ ] **Step 4: Run test to verify it passes**
1008
+
1009
+ Run: `pytest tests/api/test_fusion_route.py -v`
1010
+ Expected: PASS (3 tests)
1011
+
1012
+ - [ ] **Step 5: Commit**
1013
+
1014
+ ```bash
1015
+ git add src/api/schemas.py src/api/routes.py tests/api/test_fusion_route.py
1016
+ git commit -m "feat(api): add POST /fusion/predict route for multi-modal fusion"
1017
+ ```
1018
+
1019
+ ---
1020
+
1021
+ ### Task 7: Agent tool wrapper
1022
+
1023
+ **Files:**
1024
+ - Modify: `src/agents/tools.py`
1025
+ - Modify: `src/agents/prompts.py` (mention the new tool in the system prompt)
1026
+ - Test: `tests/agents/test_tools_fusion.py`
1027
+
1028
+ - [ ] **Step 1: Write the failing test**
1029
+
1030
+ Create `tests/agents/test_tools_fusion.py`:
1031
+
1032
+ ```python
1033
+ """Tests for the run_fusion agent tool."""
1034
+ from __future__ import annotations
1035
+
1036
+ from src.agents.tools import build_tools
1037
+
1038
+
1039
+ class TestRunFusionTool:
1040
+ def test_fusion_tool_is_registered(self) -> None:
1041
+ tools = build_tools()
1042
+ names = [t.name for t in tools]
1043
+ assert "run_fusion" in names
1044
+
1045
+ def test_fusion_tool_executes_with_only_clinical(self) -> None:
1046
+ tools = {t.name: t for t in build_tools()}
1047
+ tool = tools["run_fusion"]
1048
+ out = tool.execute(tool.input_model.model_validate({
1049
+ "clinical": {"mmse": 12.0, "age_years": 78.0},
1050
+ }))
1051
+ assert out.top_disease in {"alzheimers", "parkinsons", "other"}
1052
+ assert any(d.disease == "alzheimers" for d in out.diseases)
1053
+ ```
1054
+
1055
+ - [ ] **Step 2: Run test to verify it fails**
1056
+
1057
+ Run: `pytest tests/agents/test_tools_fusion.py -v`
1058
+ Expected: FAIL — `run_fusion` not in tool list.
1059
+
1060
+ - [ ] **Step 3: Register the tool**
1061
+
1062
+ In `src/agents/tools.py`, locate the `build_tools()` function (or whichever function returns the tool registry — match the existing pattern; e.g., a list of `Tool(...)` constructions). Add:
1063
+
1064
+ ```python
1065
+ from src.fusion.engine import fuse as fuse_engine
1066
+ from src.fusion.types import FusionInput, FusionOutput
1067
+
1068
+
1069
+ def _make_fusion_tool() -> Tool:
1070
+ return Tool(
1071
+ name="run_fusion",
1072
+ description=(
1073
+ "Combine MRI prediction, EEG prediction, and clinical-test scores "
1074
+ "into per-disease (Alzheimer's, Parkinson's, other) confidence "
1075
+ "with attribution. Pass whichever modalities are available; missing "
1076
+ "ones are skipped, not imputed."
1077
+ ),
1078
+ input_model=FusionInput,
1079
+ output_model=FusionOutput,
1080
+ execute=lambda inp: fuse_engine(inp),
1081
+ )
1082
+ ```
1083
+
1084
+ Then append `_make_fusion_tool()` to whatever list `build_tools()` returns. (If the file structures tools differently, adapt — the principle is: register it the same way `run_bbb_pipeline` is registered.)
1085
+
1086
+ In `src/agents/prompts.py` find the system prompt that lists tools and add a one-liner under the relevant section:
1087
+
1088
+ ```
1089
+ - run_fusion: combine MRI/EEG/clinical-test scores into a per-disease confidence with attribution.
1090
+ ```
1091
+
1092
+ - [ ] **Step 4: Run test to verify it passes**
1093
+
1094
+ Run: `pytest tests/agents/test_tools_fusion.py -v`
1095
+ Expected: PASS (2 tests).
1096
+
1097
+ Then run the full agent test file to make sure nothing regressed:
1098
+
1099
+ `pytest tests/agents/ -v`
1100
+ Expected: all previously-passing tests still pass.
1101
+
1102
+ - [ ] **Step 5: Commit**
1103
+
1104
+ ```bash
1105
+ git add src/agents/tools.py src/agents/prompts.py tests/agents/test_tools_fusion.py
1106
+ git commit -m "feat(agents): register run_fusion tool for multi-modal disease confidence"
1107
+ ```
1108
+
1109
+ ---
1110
+
1111
+ ### Task 8: End-to-end smoke test + README note
1112
+
1113
+ **Files:**
1114
+ - Create: `tests/integration/test_fusion_end_to_end.py`
1115
+ - Modify: `README.md` (add a one-paragraph "Fusion Engine" section under existing feature docs)
1116
+
1117
+ - [ ] **Step 1: Write the integration test**
1118
+
1119
+ Create `tests/integration/__init__.py` if not present (empty file).
1120
+
1121
+ Create `tests/integration/test_fusion_end_to_end.py`:
1122
+
1123
+ ```python
1124
+ """End-to-end: agent calls run_fusion with realistic inputs, top disease is sane."""
1125
+ from __future__ import annotations
1126
+
1127
+ import pytest
1128
+
1129
+ from src.agents.tools import build_tools
1130
+
1131
+
1132
+ @pytest.mark.parametrize(
1133
+ "scenario,expected_top",
1134
+ [
1135
+ # Strong AD signal: low MMSE + MRI flags alzheimers
1136
+ (
1137
+ {
1138
+ "mri": {
1139
+ "label_text": "alzheimers", "label": 1, "confidence": 0.85,
1140
+ "probabilities": [
1141
+ {"label_text": "control", "probability": 0.15},
1142
+ {"label_text": "alzheimers", "probability": 0.85},
1143
+ ],
1144
+ },
1145
+ "clinical": {"mmse": 14.0, "age_years": 79.0},
1146
+ },
1147
+ "alzheimers",
1148
+ ),
1149
+ # Strong PD signal: high UPDRS + slow gait + EEG flags parkinsons
1150
+ (
1151
+ {
1152
+ "eeg": {
1153
+ "label_text": "parkinsons", "label": 1, "confidence": 0.78,
1154
+ "probabilities": [
1155
+ {"label_text": "control", "probability": 0.22},
1156
+ {"label_text": "parkinsons", "probability": 0.78},
1157
+ ],
1158
+ },
1159
+ "clinical": {"updrs": 80.0, "gait_speed_m_s": 0.4, "age_years": 70.0},
1160
+ },
1161
+ "parkinsons",
1162
+ ),
1163
+ ],
1164
+ )
1165
+ def test_realistic_scenarios_pick_correct_top_disease(scenario, expected_top) -> None:
1166
+ tools = {t.name: t for t in build_tools()}
1167
+ tool = tools["run_fusion"]
1168
+ out = tool.execute(tool.input_model.model_validate(scenario))
1169
+ assert out.top_disease == expected_top
1170
+ ```
1171
+
1172
+ - [ ] **Step 2: Run test to verify it passes**
1173
+
1174
+ Run: `pytest tests/integration/test_fusion_end_to_end.py -v`
1175
+ Expected: PASS (2 cases).
1176
+
1177
+ - [ ] **Step 3: Add README paragraph**
1178
+
1179
+ In `README.md`, find the features section. Add:
1180
+
1181
+ ```markdown
1182
+ ### Fusion Engine
1183
+
1184
+ `POST /fusion/predict` (and the agent tool `run_fusion`) combines whichever of
1185
+ MRI, EEG, and clinical-test scores (MMSE, MoCA, UPDRS, gait, age) the doctor
1186
+ has uploaded into a per-disease confidence (Alzheimer's, Parkinson's, other)
1187
+ with full attribution showing how much each modality contributed.
1188
+
1189
+ Weights live in `src/fusion/weights.py` and are heuristic — adjust there.
1190
+ ```
1191
+
1192
+ - [ ] **Step 4: Run the full suite**
1193
+
1194
+ Run: `pytest -q`
1195
+ Expected: all tests pass.
1196
+
1197
+ - [ ] **Step 5: Commit**
1198
+
1199
+ ```bash
1200
+ git add tests/integration/__init__.py tests/integration/test_fusion_end_to_end.py README.md
1201
+ git commit -m "test(fusion): end-to-end smoke + README section"
1202
+ ```
1203
+
1204
+ ---
1205
+
1206
+ ## Self-review checklist (do this before declaring the plan finished)
1207
+
1208
+ 1. **Spec coverage.** The spec asks for: doctor enters extra clinical tests with preset weights → **Tasks 2, 3, 5** (weights table + normalisers + engine). MRI + EEG fusion → **Task 5**. Disease-specific (Alzheimer's, Parkinson's, other) → **Task 2** (weights are per disease). API + agent reachability → **Tasks 6, 7**. End-to-end demo → **Task 8**. ✓
1209
+
1210
+ 2. **Out of scope (do NOT build here).**
1211
+ - BBB-from-MRI: separate sub-plan #3.
1212
+ - Doctor UI form: separate sub-plan #2.
1213
+ - Patient lifestyle text: separate sub-plan #5.
1214
+ - Drug-dosing hint: separate sub-plan #6.
1215
+
1216
+ 3. **Testing surface.** Unit tests for each pure module (`weights`, `clinical`, `modality`, `engine`), integration tests at the API and agent layers, plus an end-to-end scenario test. No mocked-out internal logic — only external boundaries are stubbed (none here, since the engine is pure).
1217
+
1218
+ 4. **Logging & propagation.** Every test that asserts a log message attaches `caplog.handler` directly to the module logger because `src/core/logger.py` sets `propagate=False`. See Task 5 step 1.
1219
+
1220
+ 5. **No placeholders.** Every code block above is the full file or full appended block. No "TODO", no "implement later", no "similar to X".
1221
+
1222
+ ---
1223
+
1224
+ ## Execution handoff
1225
+
1226
+ Plan complete and saved to `docs/superpowers/plans/2026-05-02-fusion-engine.md`. Two execution options:
1227
+
1228
+ 1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, two-stage review (spec then quality) between tasks, fast iteration.
1229
+ 2. **Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints for review.
1230
+
1231
+ Which approach?