File size: 7,778 Bytes
dafcd87
201095b
 
 
dafcd87
201095b
 
 
dafcd87
201095b
 
dafcd87
 
 
 
201095b
dafcd87
 
 
 
 
 
 
 
 
201095b
dafcd87
201095b
dafcd87
 
 
 
 
201095b
dafcd87
 
 
201095b
dafcd87
 
 
 
 
 
 
 
 
 
201095b
 
 
dafcd87
 
201095b
dafcd87
 
 
 
201095b
 
dafcd87
201095b
 
dafcd87
 
201095b
 
 
dafcd87
 
201095b
 
dafcd87
 
201095b
dafcd87
 
201095b
dafcd87
 
 
 
 
 
201095b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dafcd87
 
 
201095b
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201095b
dafcd87
 
 
 
 
 
 
201095b
dafcd87
201095b
 
dafcd87
 
 
 
 
201095b
 
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
201095b
 
 
 
 
dafcd87
 
 
 
 
201095b
 
 
dafcd87
 
 
 
 
 
201095b
 
 
dafcd87
201095b
 
 
dafcd87
 
 
201095b
dafcd87
201095b
dafcd87
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
"""
Comprehensive Test Suite for MedGemma-Micro Mobile (Sub-512MB Architecture)
============================================================================
Verifies:
  1. Synthetic PPG waveform generator (all 5 rhythm classes, correct 90s shape).
  2. 1D-Conformer Biosignal Encoder output shapes, temporal attention & gradients.
  3. Temporal Cross-Attention Projector bridge with d_model=896 (Qwen2.5-0.5B).
  4. On-device Clinical RAG (<25MB) retrieval precision and latency (<5ms).
  5. Knowledge Distillation dual loss calculation.
  6. Domain coverage across ACC/AHA & ESC guidelines and pharmacology safety.
  7. Safetensors serialization and strict < 512 MB mobile budget assertion.
"""

import os
import sys
import time
import tempfile
import torch
import torch.nn as nn
import numpy as np

from pipeline import (
    PPGSimulator,
    SyntheticPPGDataset,
    PPGWaveformEncoder,
    PPGConformerEncoder,
    PPGToLLMProjector,
    PPGCrossAttentionProjector,
    KnowledgeDistillationLoss,
    MedGemmaMicroModel,
    export_and_verify_checkpoint,
    CardiologyDomainExpert,
)
from clinical_rag import ClinicalRAG, clinical_rag_engine


def test_ppg_simulator():
    print("[TEST 1/7] Testing PPGSimulator across all 5 cardiac conditions...")
    sim = PPGSimulator(sampling_rate=25, duration_sec=90)
    for cond_idx, cond_name in PPGSimulator.CLASSES.items():
        signal, label = sim.generate_window(cond_idx)
        assert label == cond_idx
        assert signal.shape == (2250, 1), f"Expected (2250, 1), got {signal.shape}"
        assert not np.isnan(signal).any(), f"NaN detected in signal for condition {cond_name}"
        assert not np.isinf(signal).any(), f"Inf detected in signal for condition {cond_name}"
    print("  -> Passed: All 5 physiological conditions generated valid 90s waveforms.")


def test_conformer_encoder():
    print("[TEST 2/7] Testing 1D-Conformer Biosignal Encoder (Attention + Depthwise CNN)...")
    batch_size = 2
    seq_len = 2250  # 90s @ 25Hz
    channels = 1
    encoder = PPGConformerEncoder(in_channels=channels, num_classes=5, latent_dim=256, num_layers=2)

    dummy_input = torch.randn(batch_size, seq_len, channels)
    logits, latent = encoder(dummy_input)

    assert logits.shape == (batch_size, 5), f"Expected logits (2, 5), got {logits.shape}"
    assert latent.shape == (batch_size, 256), f"Expected latent (2, 256), got {latent.shape}"

    # Verify backward pass & gradients through Conformer blocks
    loss = logits.sum() + latent.sum()
    loss.backward()
    for name, param in encoder.named_parameters():
        if param.requires_grad:
            assert param.grad is not None, f"Gradient missing for {name}"
    print("  -> Passed: 1D-Conformer produces valid logits & pooled latents with full gradient flow.")


def test_cross_attention_projector():
    print("[TEST 3/7] Testing Temporal Cross-Attention Projector Bridge (d_model = 896)...")
    batch_size = 2
    latent_dim = 256
    llm_dim = 896  # Qwen2.5-0.5B hidden size
    num_prefix_tokens = 4

    projector = PPGCrossAttentionProjector(sensor_dim=latent_dim, llm_dim=llm_dim, num_prefix_tokens=num_prefix_tokens)
    dummy_latent = torch.randn(batch_size, latent_dim)
    prefix_embeds = projector(dummy_latent)

    assert prefix_embeds.shape == (batch_size, num_prefix_tokens, llm_dim), (
        f"Expected prefix shape ({batch_size}, {num_prefix_tokens}, {llm_dim}), got {prefix_embeds.shape}"
    )

    # Test backward pass
    loss = prefix_embeds.sum()
    loss.backward()
    assert projector.queries.grad is not None
    print(f"  -> Passed: Cross-Attention correctly projects sensor latent to {num_prefix_tokens}x{llm_dim} tokens.")


def test_clinical_rag():
    print("[TEST 4/7] Testing On-Device Clinical RAG Guidelines Engine (< 25MB)...")
    t0 = time.perf_counter()
    res = clinical_rag_engine.retrieve("Metoprolol dosing for atrial fibrillation", condition="Atrial Fibrillation (AFib)")
    latency_ms = (time.perf_counter() - t0) * 1000.0

    assert len(res) > 0, "RAG returned no results"
    top_doc = res[0]
    assert "AFib" in top_doc["title"] or "Atrial Fibrillation" in top_doc["title"], f"Unexpected top guideline: {top_doc['title']}"
    assert top_doc["retrieval_score"] > 5.0, f"Expected high retrieval score, got {top_doc['retrieval_score']}"

    ctx = clinical_rag_engine.get_formatted_context("chest pain and tachycardia", condition="Tachycardia")
    assert "[CLINICAL GUIDELINE GROUNDING" in ctx
    print(f"  -> Passed: RAG accurately retrieved '{top_doc['title']}' in {latency_ms:.2f} ms.")


def test_distillation_loss():
    print("[TEST 5/7] Testing KnowledgeDistillationLoss...")
    criterion = KnowledgeDistillationLoss(alpha=0.5, temperature=2.0)
    batch_size = 2
    seq_len = 16
    vocab_size = 100

    student_logits = torch.randn(batch_size, seq_len, vocab_size, requires_grad=True)
    teacher_logits = torch.randn(batch_size, seq_len, vocab_size)
    labels = torch.randint(0, vocab_size, (batch_size, seq_len))
    labels[0, :3] = -100  # Masked prompt tokens

    loss = criterion(student_logits, labels, teacher_soft_targets=teacher_logits)
    assert not torch.isnan(loss), "Distillation loss returned NaN"
    loss.backward()
    assert student_logits.grad is not None
    print(f"  -> Passed: Distillation combined loss calculated: {loss.item():.4f}")


def test_cardiology_domain_coverage():
    print("[TEST 6/7] Testing domain coverage of expert cardiology rationales...")
    categories = set(p["category"] for p in CardiologyDomainExpert.EXPERT_PROMPTS)
    expected = {"Medications", "Nutrition", "Symptoms", "Recovery"}
    assert expected.issubset(categories), f"Missing categories: {expected - categories}"
    print(f"  -> Passed: Complete coverage across {len(CardiologyDomainExpert.EXPERT_PROMPTS)} expert clinical cases.")


def test_safetensors_export_and_budget():
    print("[TEST 7/7] Testing safetensors serialization and < 512 MB mobile budget...")
    class MockConfig:
        hidden_size = 896
        vocab_size = 151936

    class MockLM(nn.Module):
        def __init__(self):
            super().__init__()
            self.config = MockConfig()
            self.embed = nn.Embedding(151936, 896)
            self.linear = nn.Linear(896, 896)

        def get_input_embeddings(self):
            return self.embed

        def forward(self, inputs_embeds=None, attention_mask=None, labels=None):
            class Out:
                pass
            o = Out()
            o.logits = self.linear(inputs_embeds)
            o.loss = torch.tensor(1.23)
            return o

    mock_student = MockLM()
    model = MedGemmaMicroModel(
        student_lm=mock_student,
        encoder_type="conformer",
        projector_type="cross_attention",
    )

    with tempfile.NamedTemporaryFile(suffix=".safetensors", delete=False) as f:
        tmp_path = f.name

    try:
        size_mb = export_and_verify_checkpoint(model, output_path=tmp_path, target_dtype=torch.float16, budget_limit_mb=512.0)
        assert size_mb < 512.0, f"Export exceeded 512 MB: {size_mb} MB"
        print(f"  -> Passed: Unified mobile model serialized at {size_mb:.2f} MB (< 512 MB ceiling).")
    finally:
        if os.path.exists(tmp_path):
            os.remove(tmp_path)


def run_all_tests():
    print("=" * 70)
    print("Running MedGemma-Micro Mobile (Sub-512MB) Unit Test Suite")
    print("=" * 70)
    test_ppg_simulator()
    test_conformer_encoder()
    test_cross_attention_projector()
    test_clinical_rag()
    test_distillation_loss()
    test_cardiology_domain_coverage()
    test_safetensors_export_and_budget()
    print("=" * 70)
    print("ALL TESTS PASSED SUCCESSFULLY!")
    print("=" * 70)


if __name__ == "__main__":
    run_all_tests()