docs(plan): clinical platform roadmap + fusion engine plan
Browse filesRoadmap 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?
|