File size: 59,907 Bytes
dafcd87
201095b
 
 
 
 
dafcd87
 
201095b
 
 
dafcd87
201095b
 
dafcd87
201095b
dafcd87
 
 
 
 
 
 
 
 
efc501a
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
efc501a
 
 
 
 
 
 
 
 
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63139fb
 
dafcd87
 
 
 
 
 
 
 
63139fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dafcd87
 
 
63139fb
dafcd87
63139fb
 
 
 
 
 
dafcd87
63139fb
dafcd87
 
 
 
 
 
 
 
63139fb
dafcd87
 
 
 
 
 
 
 
 
63139fb
dafcd87
 
201095b
dafcd87
 
 
 
63139fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201095b
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201095b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b75e3d1
201095b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b75e3d1
 
201095b
 
 
 
 
dafcd87
 
201095b
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201095b
dafcd87
 
201095b
dafcd87
201095b
 
dafcd87
201095b
 
dafcd87
 
201095b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201095b
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201095b
dafcd87
 
 
 
201095b
dafcd87
 
 
 
 
201095b
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51d622b
dafcd87
51d622b
 
 
 
 
 
 
5b4bf2d
 
 
 
dafcd87
5b4bf2d
dafcd87
 
 
 
 
 
 
 
 
 
5b4bf2d
51d622b
 
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201095b
dafcd87
 
 
 
201095b
 
 
 
dafcd87
 
 
 
 
 
 
 
201095b
 
dafcd87
 
 
201095b
dafcd87
201095b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dafcd87
201095b
 
 
 
 
 
 
 
 
 
 
 
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
efc501a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63139fb
 
 
 
 
 
efc501a
 
 
63139fb
 
 
 
 
efc501a
63139fb
efc501a
 
 
63139fb
efc501a
 
 
dafcd87
 
 
 
 
 
201095b
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201095b
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201095b
dafcd87
 
 
 
 
 
51d622b
dafcd87
 
201095b
51d622b
 
dafcd87
 
 
 
51d622b
 
 
 
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201095b
 
51d622b
dafcd87
201095b
 
51d622b
dafcd87
 
 
 
efc501a
 
dafcd87
 
 
 
 
 
 
 
 
201095b
 
dafcd87
 
201095b
dafcd87
201095b
dafcd87
201095b
dafcd87
 
 
 
 
 
 
 
 
 
 
 
 
51d622b
 
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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
"""
MedGemma-Micro: Sub-512MB Multi-Task Cardiology Mobile Edge Model Pipeline
==========================================================================
Distilled from `google/medgemma-1.5-4b-it` into an ultra-compact student model
(Qwen2.5-0.5B / SmolLM2 + 1D-Conformer Biosignal Encoder + Cross-Attention Bridge)
for iOS (Core ML / Metal) and Android (LiteRT / GGUF) mobile devices (>= 8GB RAM).

Target Constraints:
  - Hardware: iOS and Android Mobile Devices (>= 8GB RAM)
  - Memory Budget: Combined weights strictly < 512 MB in .safetensors format.
  - Modality A: 90-second continuous PPG pulse window (25 Hz) for anomaly detection.
  - Modality B: Student language model for cardiology clinical reasoning.
  - Modality Fusion: Temporal Cross-Attention projection bridge conditioning LLM on sensor tokens.
  - Clinical RAG: Zero-cloud on-device ACC/AHA & ESC guidelines grounding (< 25 MB).

Author: Principal ML Systems & Mobile Edge-AI Engineering
"""

import os
import sys
import math
import time
import json
import logging
import argparse
from typing import Dict, List, Tuple, Optional, Union, Any

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
import numpy as np

# Third-party HuggingFace & serialization libraries
import safetensors.torch
from transformers import (
    AutoTokenizer,
    AutoModelForCausalLM,
    BitsAndBytesConfig,
    PreTrainedModel,
    PreTrainedTokenizer,
)

# Wear OS smartwatch integration
from wearos_ppg_adapter import (
    WearOSPPGPoint,
    WearOSPacketProtocol,
    WearOSPPGAdapter,
    WearOSStreamBuffer,
    WearOSSignalQuality,
)

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format="[%(asctime)s] [%(levelname)s] %(message)s",
    datefmt="%H:%M:%S",
)
logger = logging.getLogger("MedGemmaMicro")

# =====================================================================
# 1. SYNTHETIC PPG WAVEFORM SIMULATOR (Physiological Ground Truth)
# =====================================================================

class PPGSimulator:
    """
    Generates realistic synthetic 90-second photoplethysmography (PPG) waveforms
    reflecting hemodynamic pulsations, dicrotic notch, respiratory sinus arrhythmia (RSA),
    and diverse cardiac arrhythmias (AFib, Bradycardia, Tachycardia, PVC).
    """

    CLASSES = {
        0: "Normal Sinus Rhythm",
        1: "Atrial Fibrillation (AFib)",
        2: "Bradycardia",
        3: "Tachycardia",
        4: "Premature Ventricular Contractions (PVC)",
    }

    def __init__(self, sampling_rate: int = 25, duration_sec: int = 90):
        self.fs = sampling_rate
        self.duration = duration_sec
        self.num_samples = sampling_rate * duration_sec  # 2250 samples at 25 Hz

    def _generate_single_pulse(self, t_pulse: np.ndarray, pulse_width: float) -> np.ndarray:
        """Models the systolic and diastolic (dicrotic) peaks of a peripheral arterial pulse."""
        # Systolic upstroke and peak (steep Gaussian)
        systolic = np.exp(-((t_pulse - 0.2 * pulse_width) ** 2) / (2 * (0.08 * pulse_width) ** 2))
        # Dicrotic notch and diastolic wave
        diastolic = 0.35 * np.exp(-((t_pulse - 0.5 * pulse_width) ** 2) / (2 * (0.12 * pulse_width) ** 2))
        return systolic + diastolic

    def generate_window(self, condition: int) -> Tuple[np.ndarray, int]:
        """
        Synthesizes a 90-second PPG signal for a specified condition code.
        Covers continuous, realistic physiological heart rate spectra with natural
        respiratory sinus arrhythmia (RSA), heart rate variability, and sensor artifacts.
        Returns:
            signal: np.ndarray of shape (num_samples, 1) normalized to zero-mean unit-variance.
            condition: integer label (0 to 4).
        """
        total_time = self.duration
        t = np.linspace(0, total_time, self.num_samples, endpoint=False)
        signal = np.zeros(self.num_samples)

        # Baseline wander (respiration ~0.18-0.26 Hz & optical drift ~0.04 Hz)
        resp_freq = float(np.random.uniform(0.18, 0.26))
        respiration = 0.12 * np.sin(2 * np.pi * resp_freq * t)
        low_drift = 0.06 * np.sin(2 * np.pi * 0.04 * t + float(np.random.uniform(0, np.pi)))

        # Continuous physiological heart rate distributions
        if condition == 0:  # Normal Sinus Rhythm (52-98 bpm, healthy resting range)
            target_bpm = float(np.random.uniform(52, 98))
            base_rr = 60.0 / target_bpm
            num_beats = int(total_time * target_bpm / 60 + 10)
            rr_intervals = []
            cur_time = 0.0
            for _ in range(num_beats):
                rsa_mod = 0.04 * np.sin(2 * np.pi * resp_freq * cur_time)
                rr = base_rr + rsa_mod + float(np.random.normal(0, 0.02))
                rr_intervals.append(max(0.55, min(1.25, rr)))
                cur_time += rr
        elif condition == 1:  # Atrial Fibrillation (Irregularly irregular, 65-140 bpm)
            mean_bpm = float(np.random.uniform(75, 135))
            num_beats = int(total_time * mean_bpm / 60 * 1.4)
            rr_intervals = np.random.gamma(shape=3.2, scale=(60.0 / mean_bpm) / 3.2, size=num_beats).tolist()
        elif condition == 2:  # Bradycardia (<52 bpm, regular)
            target_bpm = float(np.random.uniform(36, 51.5))
            rr_intervals = [60.0 / target_bpm + float(np.random.normal(0, 0.025)) for _ in range(int(total_time))]
        elif condition == 3:  # Tachycardia (>101 bpm, regular)
            target_bpm = float(np.random.uniform(102, 155))
            rr_intervals = [60.0 / target_bpm + float(np.random.normal(0, 0.012)) for _ in range(int(total_time * 3))]
        elif condition == 4:  # PVC (Baseline 55-90 bpm with ectopic beats & compensatory pauses)
            target_bpm = float(np.random.uniform(55, 88))
            base_rr = 60.0 / target_bpm
            rr_intervals = []
            cur_t = 0.0
            ectopic_rate = float(np.random.uniform(0.08, 0.18))
            while cur_t < total_time + 5:
                if np.random.rand() < ectopic_rate:
                    coupling = float(np.random.uniform(0.52, 0.68))
                    pause = 2.0 - coupling + float(np.random.normal(0, 0.02))
                    rr_intervals.append(base_rr * coupling)
                    rr_intervals.append(base_rr * pause)
                    cur_t += base_rr * (coupling + pause)
                else:
                    rr_intervals.append(base_rr + float(np.random.normal(0, 0.02)))
                    cur_t += base_rr

        # Construct continuous waveform from beat timestamps
        beat_times = np.cumsum(rr_intervals)
        for i, beat_t in enumerate(beat_times):
            if beat_t >= total_time:
                break
            pulse_w = rr_intervals[i] if i < len(rr_intervals) else 0.8
            amp = float(np.random.uniform(0.55, 1.25)) if condition == 1 else 1.0
            idx_start = int(beat_t * self.fs)
            idx_end = min(self.num_samples, idx_start + int(pulse_w * self.fs))
            pulse_samples = idx_end - idx_start
            if pulse_samples > 0:
                t_pulse = np.linspace(0, pulse_w, pulse_samples, endpoint=False)
                pulse_shape = amp * self._generate_single_pulse(t_pulse, pulse_w)
                signal[idx_start:idx_end] += pulse_shape

        # Add physiological baseline wander + thermal high-frequency sensor noise
        noise = np.random.normal(0, 0.035, self.num_samples)
        raw_ppg = signal + respiration + low_drift + noise

        # Z-score normalization (standard mobile front-end processing)
        normalized_ppg = (raw_ppg - np.mean(raw_ppg)) / (np.std(raw_ppg) + 1e-6)
        return normalized_ppg.reshape(-1, 1).astype(np.float32), condition


def extract_hemodynamic_features(signal: np.ndarray, fs: int = 25) -> Dict[str, float]:
    """
    Extracts deterministic hemodynamic metrics from a 90s PPG signal:
      - Estimated heart rate (BPM)
      - RMSSD (ms) & SDNN (ms)
      - Coefficient of variation of RR intervals (CV_RR = SDNN / Mean_RR)
      - Premature ectopic beat ratio (short RR < 0.78 * mean followed by compensatory pause)
    """
    flat = np.asarray(signal, dtype=np.float64).flatten()
    threshold = np.mean(flat) + 0.65 * np.std(flat)
    min_dist = int(fs * 0.30)  # 300ms refractory period (up to 200 BPM)
    peaks = []

    i = 1
    while i < len(flat) - 1:
        if flat[i] > threshold and flat[i] > flat[i - 1] and flat[i] >= flat[i + 1]:
            peaks.append(i)
            i += min_dist
        else:
            i += 1

    if len(peaks) < 4:
        return {
            "mean_bpm": 72.0,
            "rmssd_ms": 35.0,
            "sdnn_ms": 40.0,
            "cv_rr": 0.05,
            "pnn50": 5.0,
            "premature_ratio": 0.0,
            "peak_count": len(peaks),
        }

    rr_intervals_sec = np.diff(peaks) / fs
    rr_ms = rr_intervals_sec * 1000.0
    mean_rr = float(np.mean(rr_ms))
    mean_bpm = float(np.clip(60000.0 / max(mean_rr, 1.0), 30.0, 220.0))
    sdnn = float(np.std(rr_ms))
    cv_rr = float(sdnn / (mean_rr + 1e-6))
    rmssd = float(np.sqrt(np.mean(np.diff(rr_ms) ** 2))) if len(rr_ms) >= 2 else 35.0
    pnn50 = float(np.mean(np.abs(np.diff(rr_ms)) > 50.0) * 100.0) if len(rr_ms) >= 2 else 0.0

    # Premature beat detection
    premature_count = 0
    for j in range(len(rr_ms) - 1):
        if rr_ms[j] < 0.78 * mean_rr and rr_ms[j + 1] > 1.18 * mean_rr:
            premature_count += 1
    premature_ratio = float(premature_count / max(1, len(rr_ms)))

    return {
        "mean_bpm": round(mean_bpm, 1),
        "rmssd_ms": round(rmssd, 1),
        "sdnn_ms": round(sdnn, 1),
        "cv_rr": round(cv_rr, 3),
        "pnn50": round(pnn50, 1),
        "premature_ratio": round(premature_ratio, 3),
        "peak_count": len(peaks),
    }


def calibrate_rhythm_prediction(
    predicted_idx: int,
    probabilities: np.ndarray,
    hemodynamic_metrics: Dict[str, float],
) -> Tuple[int, np.ndarray, str]:
    """
    Physiological sanity calibration combining neural classification with hemodynamic ground truth.
    Eliminates false-positive PVC/AFib flipping on normal resting heart rates (52-98 BPM).
    """
    bpm = float(hemodynamic_metrics.get("mean_bpm", 72.0))
    cv_rr = float(hemodynamic_metrics.get("cv_rr", 0.05))
    premature_ratio = float(hemodynamic_metrics.get("premature_ratio", 0.0))
    calibrated_idx = predicted_idx
    reason = "Neural prediction verified"

    # Rule 1: Normal resting heart rate (52-98 BPM) with low variability & no premature beats CANNOT be PVC or AFib
    if (predicted_idx == 4 or predicted_idx == 1) and (52.0 <= bpm <= 98.0):
        if premature_ratio < 0.04 and cv_rr < 0.12:
            calibrated_idx = 0
            reason = f"Corrected to Normal Sinus: Resting HR {bpm:.1f} BPM is regular (CV_RR={cv_rr:.2f}, Ectopic={premature_ratio:.2f})"

    # Rule 2: Bradycardia check: regular rhythm with HR <= 51.5 BPM
    if predicted_idx == 0 and bpm < 51.5 and cv_rr < 0.12:
        calibrated_idx = 2
        reason = f"Calibrated to Sinus Bradycardia: Regular rate {bpm:.1f} < 52 BPM"
    elif predicted_idx == 2 and bpm >= 55.0 and cv_rr < 0.12:
        calibrated_idx = 0
        reason = f"Calibrated to Normal Sinus: Rate {bpm:.1f} BPM >= 55 BPM is within normal resting bounds"

    # Rule 3: Tachycardia check: regular rhythm with HR >= 101 BPM
    if predicted_idx == 0 and bpm >= 101.0 and cv_rr < 0.14:
        calibrated_idx = 3
        reason = f"Calibrated to Sinus Tachycardia: Regular rate {bpm:.1f} >= 101 BPM"
    elif predicted_idx == 3 and bpm < 98.0 and cv_rr < 0.12:
        calibrated_idx = 0
        reason = f"Calibrated to Normal Sinus: Rate {bpm:.1f} BPM < 98 BPM is within normal resting bounds"

    # Rule 4: AFib requires genuine chaotic RR irregularity (CV_RR >= 0.14)
    if predicted_idx == 1 and cv_rr < 0.10:
        if bpm < 52.0:
            calibrated_idx = 2
        elif bpm >= 101.0:
            calibrated_idx = 3
        else:
            calibrated_idx = 0
        reason = f"Corrected from AFib: Rhythm is regular (CV_RR={cv_rr:.2f} < 0.10)"

    # Rule 5: PVC requires detectable premature beats
    if predicted_idx == 4 and premature_ratio < 0.03:
        calibrated_idx = 0
        reason = f"Corrected from PVC: No premature beats detected (ratio={premature_ratio:.2f})"

    # Update probability distribution if calibrated
    new_probs = np.array(probabilities, dtype=np.float32).copy()
    if calibrated_idx != predicted_idx:
        new_probs[calibrated_idx] = max(float(new_probs[calibrated_idx]), 0.85)
        new_probs /= np.sum(new_probs)

    return calibrated_idx, new_probs, reason


class SyntheticPPGDataset(Dataset):
    """PyTorch Dataset wrapper for synthetic multi-condition continuous PPG streams."""

    def __init__(self, num_samples: int = 120, sampling_rate: int = 25, duration_sec: int = 90):
        self.simulator = PPGSimulator(sampling_rate=sampling_rate, duration_sec=duration_sec)
        self.data: List[Tuple[np.ndarray, int]] = []
        for i in range(num_samples):
            cond = i % 5  # Balance all 5 cardiac states evenly
            ppg_win, label = self.simulator.generate_window(cond)
            self.data.append((ppg_win, label))

    def __len__(self) -> int:
        return len(self.data)

    def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
        signal, label = self.data[idx]
        return torch.from_numpy(signal), torch.tensor(label, dtype=torch.long)


# =====================================================================
# 2. MODALITY A: 1D-CNN + BiLSTM PPG ENCODER (LEGACY COMPATIBILITY)
# =====================================================================

class ResidualBlock1D(nn.Module):
    """Temporal residual convolution block with LayerNorm and GELU activations."""

    def __init__(self, channels: int, kernel_size: int = 5):
        super().__init__()
        padding = kernel_size // 2
        self.conv1 = nn.Conv1d(channels, channels, kernel_size, padding=padding, bias=False)
        self.norm1 = nn.GroupNorm(num_groups=4, num_channels=channels)
        self.act1 = nn.GELU()
        self.conv2 = nn.Conv1d(channels, channels, kernel_size, padding=padding, bias=False)
        self.norm2 = nn.GroupNorm(num_groups=4, num_channels=channels)
        self.act2 = nn.GELU()

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        res = x
        out = self.act1(self.norm1(self.conv1(x)))
        out = self.norm2(self.conv2(out))
        return self.act2(out + res)


# =====================================================================
# 2B. 1D-CONFORMER BIOSIGNAL ENCODER (PRIMARY MOBILE ARCHITECTURE)
# =====================================================================

class ConformerFeedForward1D(nn.Module):
    """Macaron-style Feed-Forward Network with GELU and dropout."""

    def __init__(self, d_model: int = 256, d_ff: int = 512, dropout: float = 0.1):
        super().__init__()
        self.norm = nn.LayerNorm(d_model)
        self.fc1 = nn.Linear(d_model, d_ff)
        self.act = nn.GELU()
        self.dropout1 = nn.Dropout(dropout)
        self.fc2 = nn.Linear(d_ff, d_model)
        self.dropout2 = nn.Dropout(dropout)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        res = x
        x = self.norm(x)
        x = self.dropout1(self.act(self.fc1(x)))
        x = self.dropout2(self.fc2(x))
        return res + 0.5 * x  # Macaron half-step residual


class ConformerConvModule1D(nn.Module):
    """
    Depthwise-Separable Convolution Module for local pulse morphology extraction:
      LayerNorm -> Pointwise Conv -> GLU -> Depthwise Conv1d (k=15) -> GroupNorm -> GELU -> Pointwise Conv
    """

    def __init__(self, d_model: int = 256, kernel_size: int = 15, dropout: float = 0.1):
        super().__init__()
        self.norm = nn.LayerNorm(d_model)
        self.pointwise1 = nn.Conv1d(d_model, d_model * 2, kernel_size=1)
        self.depthwise = nn.Conv1d(
            d_model,
            d_model,
            kernel_size=kernel_size,
            padding=kernel_size // 2,
            groups=d_model,
            bias=False,
        )
        self.norm_conv = nn.GroupNorm(8, d_model)
        self.act = nn.GELU()
        self.pointwise2 = nn.Conv1d(d_model, d_model, kernel_size=1)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # x: [B, T, D]
        res = x
        x = self.norm(x)
        # Conv1d expects [B, D, T]
        x = x.transpose(1, 2)
        x = self.pointwise1(x)
        # GLU gating
        x1, x2 = x.chunk(2, dim=1)
        x = x1 * torch.sigmoid(x2)
        x = self.act(self.norm_conv(self.depthwise(x)))
        x = self.dropout(self.pointwise2(x))
        x = x.transpose(1, 2)
        return res + x


class ConformerBlock1D(nn.Module):
    """
    Unified 1D Conformer Block combining:
      1. Half-step Feed-Forward Module
      2. Multi-Head Self-Attention Module (MHSA)
      3. Depthwise-Separable Convolution Module
      4. Second Half-step Feed-Forward Module
      5. LayerNorm
    """

    def __init__(self, d_model: int = 256, n_heads: int = 4, d_ff: int = 512, dropout: float = 0.1):
        super().__init__()
        self.ffn1 = ConformerFeedForward1D(d_model, d_ff, dropout)
        self.norm_mha = nn.LayerNorm(d_model)
        self.mha = nn.MultiheadAttention(d_model, n_heads, dropout=dropout, batch_first=True)
        self.dropout_mha = nn.Dropout(dropout)
        self.conv_module = ConformerConvModule1D(d_model, kernel_size=15, dropout=dropout)
        self.ffn2 = ConformerFeedForward1D(d_model, d_ff, dropout)
        self.final_norm = nn.LayerNorm(d_model)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # 1. First FFN
        x = self.ffn1(x)
        # 2. Multi-Head Self-Attention
        normed = self.norm_mha(x)
        attn_out, _ = self.mha(normed, normed, normed)
        x = x + self.dropout_mha(attn_out)
        # 3. Convolution Module
        x = self.conv_module(x)
        # 4. Second FFN
        x = self.ffn2(x)
        return self.final_norm(x)


class PPGConformerEncoder(nn.Module):
    """
    Mobile-grade 1D-Conformer Biosignal Encoder for iOS (Core ML) & Android (LiteRT):
      - Ingests: [Batch, Time=2250 (90s @ 25Hz), Channels=1]
      - Multi-scale convolutional stem downsamples temporal rate ~32x (2250 -> 70 tokens)
      - Dual Conformer blocks extract local systolic/diastolic waves & global chaotic RR patterns
      - Multi-Head Attention Pooling aggregates temporal patches into a global context latent
      - Outputs:
          1) 5-class abnormality logits: [Batch, 5]
          2) Global rhythm latent: [Batch, 256]
          3) Temporal patch embeddings: [Batch, 70, 256] (for localized Cross-Attention)
    """

    def __init__(self, in_channels: int = 1, num_classes: int = 5, latent_dim: int = 256, num_layers: int = 2):
        super().__init__()
        self.latent_dim = latent_dim

        # Multiscale downsampling stem: 2250 -> 1125 -> 562 -> 281 -> 140 -> 70
        self.stem = nn.Sequential(
            nn.Conv1d(in_channels, 32, kernel_size=15, stride=2, padding=7, bias=False),  # 2250 -> 1125
            nn.GroupNorm(4, 32),
            nn.GELU(),
            nn.MaxPool1d(kernel_size=2, stride=2),  # 1125 -> 562
            nn.Conv1d(32, 64, kernel_size=7, stride=2, padding=3, bias=False),  # 562 -> 281
            nn.GroupNorm(8, 64),
            nn.GELU(),
            nn.MaxPool1d(kernel_size=2, stride=2),  # 281 -> 140
            nn.Conv1d(64, 128, kernel_size=5, stride=2, padding=2, bias=False),  # 140 -> 70
            nn.GroupNorm(8, 128),
            nn.GELU(),
            nn.Conv1d(128, latent_dim, kernel_size=3, stride=1, padding=1, bias=False),  # 70 -> 70
            nn.GroupNorm(16, latent_dim),
            nn.GELU(),
        )

        # Conformer blocks
        self.conformer_layers = nn.ModuleList([
            ConformerBlock1D(d_model=latent_dim, n_heads=4, d_ff=512, dropout=0.1)
            for _ in range(num_layers)
        ])

        # Temporal LayerNorm
        self.pool_norm = nn.LayerNorm(latent_dim)

        # Cardiac condition classification head
        self.classifier = nn.Sequential(
            nn.Linear(latent_dim, 64),
            nn.GELU(),
            nn.Dropout(0.15),
            nn.Linear(64, num_classes),
        )

    def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
        """
        Args:
            x: PPG tensor of shape [B, T, C] (e.g. [B, 2250, 1])
        Returns:
            logits: [B, num_classes]
            pooled_latent: [B, latent_dim]
        """
        # [B, T, C] -> [B, C, T] for Conv1d
        x = x.transpose(1, 2)
        feat = self.stem(x)  # [B, 256, 70]
        feat = feat.transpose(1, 2)  # [B, 70, 256]

        for layer in self.conformer_layers:
            feat = layer(feat)

        # Global rhythm temporal pooling across 70 temporal tokens
        pooled = self.pool_norm(feat.mean(dim=1))  # [B, 256]

        logits = self.classifier(pooled)
        return logits, pooled


class PPGWaveformEncoder(nn.Module):
    """
    Ultra-lightweight sensor encoder designed for edge execution:
      - Ingests: [Batch, Time=2250 (90s @ 25Hz), Channels=1]
      - 1D-CNN front-end downsamples temporal rate ~32x (2250 -> ~71 steps)
      - 2-layer BiLSTM captures cardiac rhythm variability across the 90s window
      - Outputs:
          1) 5-class abnormality logits: [Batch, 5]
          2) Latent temporal context embedding: [Batch, 256]
    """

    def __init__(self, in_channels: int = 1, num_classes: int = 5, latent_dim: int = 256):
        super().__init__()
        self.latent_dim = latent_dim

        # Front-end multiscale temporal feature extractor
        self.stem = nn.Sequential(
            nn.Conv1d(in_channels, 32, kernel_size=15, stride=2, padding=7, bias=False),  # 2250 -> 1125
            nn.GroupNorm(4, 32),
            nn.GELU(),
            nn.MaxPool1d(kernel_size=2, stride=2),  # 1125 -> 562
        )

        self.stage1 = nn.Sequential(
            nn.Conv1d(32, 64, kernel_size=7, stride=2, padding=3, bias=False),  # 562 -> 281
            nn.GroupNorm(8, 64),
            nn.GELU(),
            ResidualBlock1D(64, kernel_size=5),
        )

        self.stage2 = nn.Sequential(
            nn.Conv1d(64, 128, kernel_size=5, stride=2, padding=2, bias=False),  # 281 -> 141
            nn.GroupNorm(8, 128),
            nn.GELU(),
            ResidualBlock1D(128, kernel_size=5),
        )

        self.stage3 = nn.Sequential(
            nn.Conv1d(128, latent_dim, kernel_size=3, stride=2, padding=1, bias=False),  # 141 -> 71
            nn.GroupNorm(16, latent_dim),
            nn.GELU(),
        )

        # BiLSTM for temporal dynamics & heart rate variability modeling
        self.bilstm = nn.LSTM(
            input_size=latent_dim,
            hidden_size=latent_dim // 2,
            num_layers=2,
            batch_first=True,
            bidirectional=True,
            dropout=0.1,
        )

        # Cardiac condition classification head
        self.classifier = nn.Sequential(
            nn.Linear(latent_dim, 64),
            nn.GELU(),
            nn.Dropout(0.15),
            nn.Linear(64, num_classes),
        )

    def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
        """
        Args:
            x: PPG tensor of shape [B, T, C] (e.g. [B, 2250, 1])
        Returns:
            logits: [B, num_classes]
            temporal_latent: [B, latent_dim] (pooled rhythm representation)
        """
        # Transpose [B, T, C] -> [B, C, T] for Conv1d
        x = x.transpose(1, 2)
        feat = self.stem(x)
        feat = self.stage1(feat)
        feat = self.stage2(feat)
        feat = self.stage3(feat)  # Shape: [B, 256, ~71]

        # Prepare for BiLSTM: [B, C, T] -> [B, T, C]
        feat = feat.transpose(1, 2)
        lstm_out, _ = self.bilstm(feat)  # Shape: [B, ~71, 256]

        # Global rhythm pooling (mean over temporal tokens)
        temporal_latent = lstm_out.mean(dim=1)  # Shape: [B, 256]

        # Classification logits
        logits = self.classifier(temporal_latent)
        return logits, temporal_latent


# =====================================================================
# 3. MODALITY FUSION: TEMPORAL CROSS-ATTENTION & PROJECTION BRIDGES
# =====================================================================

class PPGCrossAttentionProjector(nn.Module):
    """
    Temporal Cross-Attention Projection Bridge connecting Conformer / CNN
    biosignal features to the Qwen2.5-0.5B Student LM (d_model = 896).

    Uses K learnable query tokens that cross-attend to sensor latent representations,
    producing K rhythm-conditioned soft prompt tokens.
    """

    def __init__(self, sensor_dim: int = 256, llm_dim: int = 896, num_prefix_tokens: int = 4):
        super().__init__()
        self.num_prefix_tokens = num_prefix_tokens
        self.llm_dim = llm_dim
        self.sensor_dim = sensor_dim

        # K learnable continuous rhythm query tokens
        self.queries = nn.Parameter(torch.randn(1, num_prefix_tokens, llm_dim) * 0.02)

        # Projection from sensor_dim to llm_dim
        self.sensor_proj = nn.Sequential(
            nn.Linear(sensor_dim, llm_dim),
            nn.GELU(),
            nn.LayerNorm(llm_dim),
        )

        # Cross-attention module
        self.cross_attn = nn.MultiheadAttention(embed_dim=llm_dim, num_heads=4, batch_first=True)
        self.norm = nn.LayerNorm(llm_dim)

        # Feed-forward post-processing
        self.ffn = nn.Sequential(
            nn.Linear(llm_dim, llm_dim * 2),
            nn.GELU(),
            nn.Linear(llm_dim * 2, llm_dim),
            nn.Dropout(0.1),
        )
        self.final_norm = nn.LayerNorm(llm_dim)

    def forward(self, sensor_latent: torch.Tensor) -> torch.Tensor:
        """
        Args:
            sensor_latent: [B, sensor_dim] or [B, T, sensor_dim]
        Returns:
            prefix_embeddings: [B, num_prefix_tokens, llm_dim]
        """
        batch_size = sensor_latent.size(0)

        # If 2D [B, sensor_dim], expand to [B, 1, sensor_dim]
        if sensor_latent.dim() == 2:
            sensor_tokens = sensor_latent.unsqueeze(1)
        else:
            sensor_tokens = sensor_latent

        # Project sensor features to LLM dimension
        sensor_kv = self.sensor_proj(sensor_tokens)  # [B, T, llm_dim]

        # Expand learnable queries for batch
        q = self.queries.expand(batch_size, -1, -1)  # [B, K, llm_dim]

        # Cross-attend: queries attend to sensor KV
        attn_out, _ = self.cross_attn(query=q, key=sensor_kv, value=sensor_kv)
        x = self.norm(q + attn_out)

        # FFN refinement
        out = self.final_norm(x + self.ffn(x))
        return out


class PPGToLLMProjector(nn.Module):
    """
    Legacy MLP Projection Bridge connecting sensor encoder to Student LM.
    """

    def __init__(self, sensor_dim: int = 256, llm_dim: int = 896, num_prefix_tokens: int = 4):
        super().__init__()
        self.num_prefix_tokens = num_prefix_tokens
        self.llm_dim = llm_dim

        self.bridge = nn.Sequential(
            nn.Linear(sensor_dim, 512),
            nn.GELU(),
            nn.Dropout(0.1),
            nn.Linear(512, llm_dim * num_prefix_tokens),
            nn.LayerNorm(llm_dim * num_prefix_tokens),
        )

    def forward(self, sensor_latent: torch.Tensor) -> torch.Tensor:
        """
        Args:
            sensor_latent: [B, sensor_dim] (from PPGWaveformEncoder)
        Returns:
            prefix_embeddings: [B, num_prefix_tokens, llm_dim]
        """
        batch_size = sensor_latent.size(0)
        proj = self.bridge(sensor_latent)
        prefix_embeddings = proj.view(batch_size, self.num_prefix_tokens, self.llm_dim)
        return prefix_embeddings


# =====================================================================
# 4. TEACHER SETUP & SYNTHETIC CARDIOLOGY REASONING GENERATION
# =====================================================================

class CardiologyDomainExpert:
    """
    Clinical domain templates and curated expert rationales covering:
      1. Medications (Beta-blockers, Anticoagulants, Statins, ACEi, Antiarrhythmics)
      2. Nutrition (Sodium restriction < 1500mg, Potassium/Magnesium, DASH guidelines)
      3. Symptoms (Angina, Palpitations, Dyspnea, Syncope, Peripheral Edema)
      4. Post-Anomaly Exercise Limits & Autonomic Recovery (HRV, Sleep Staging)
    """

    EXPERT_PROMPTS = [
        # --- Category 1: Medications ---
        {
            "category": "Medications",
            "prompt": "Patient with detected Atrial Fibrillation (AFib) on mobile PPG sensor. What are first-line rate control and stroke prevention medications?",
            "teacher_response": "For Atrial Fibrillation rate control, first-line agents include cardioselective beta-blockers (e.g., Metoprolol succinate 25-50mg daily or Bisoprolol) or non-dihydropyridine calcium channel blockers (Diltiazem). For stroke prevention, calculate CHA2DS2-VASc score; Direct Oral Anticoagulants (DOACs like Apixaban 5mg BID or Rivaroxaban) are preferred over Warfarin unless moderate-to-severe mitral stenosis or mechanical heart valves are present. Avoid sudden exertion.",
        },
        {
            "category": "Medications",
            "prompt": "Review contraindications and interactions when starting Metoprolol for sinus tachycardia.",
            "teacher_response": "Metoprolol is contraindicated in second- or third-degree AV block, severe sinus bradycardia (HR < 45 bpm), cardiogenic shock, and decompensated acute heart failure. Exercise caution in severe reactive airway disease (asthma). Note interactions with CYP2D6 inhibitors (Fluoxetine, Paroxetine) and concurrent verapamil/diltiazem which significantly elevate AV node block risk.",
        },
        # --- Category 2: Heart-Healthy Nutrition ---
        {
            "category": "Nutrition",
            "prompt": "Provide clinical nutrition guidelines for managing hypertension and preventing arrhythmia episodes.",
            "teacher_response": "Prescribe the DASH dietary framework: restrict dietary sodium strictly below 1,500 to 2,000 mg/day to lower vascular peripheral resistance. Optimize dietary potassium (3,500-4,700 mg/day from leafy greens, avocados) provided renal function is preserved (eGFR > 45). Maintain adequate magnesium to stabilize cardiac cardiomyocyte membrane potentials. Strictly minimize binge alcohol and excessive caffeine, both well-known adrenergic triggers for atrial ectopy.",
        },
        {
            "category": "Nutrition",
            "prompt": "What dietary modifications assist heart failure recovery and prevent fluid retention?",
            "teacher_response": "Maintain strict sodium restriction (< 1,500 mg daily) and fluid restriction (1.5 - 2.0 L/day if congestive symptoms are present). Prioritize omega-3 polyunsaturated fatty acids (salmon, walnuts) for anti-inflammatory endothelial support. Monitor daily morning weights: a rapid gain of >2-3 lbs in 24 hours indicates fluid retention requiring diuretic adjustment.",
        },
        # --- Category 3: Symptoms & Clinical Triage ---
        {
            "category": "Symptoms",
            "prompt": "Mobile PPG sensor flagged sustained tachycardia (>130 bpm). When is this an emergency vs outpatient evaluation?",
            "teacher_response": "Immediate Emergency Department (911) transfer is mandatory if tachycardia is accompanied by 'red flag' symptoms: acute crushing substernal chest pressure, radiation to left arm or jaw (acute coronary syndrome), diaphoresis, exertional dyspnea at rest, presyncope, or true syncope. If patient is completely asymptomatic, resting calmly, and heart rate settles post-hydration, arrange urgent outpatient 12-lead ECG and Holter monitoring.",
        },
        {
            "category": "Symptoms",
            "prompt": "Patient reports frequent skipped beats (PVCs) on mobile PPG monitor. How should symptoms be correlated with clinical risk?",
            "teacher_response": "Isolated premature ventricular contractions (PVCs) in an otherwise structurally normal heart are typically benign. However, frequent palpitations accompanied by dizziness, lightheadedness, or shortness of breath warrant investigation of PVC burden (>10-15% burden risks tachycardia-induced cardiomyopathy). Check serum electrolytes (potassium, magnesium) and order an echocardiogram.",
        },
        # --- Category 4: Post-Anomaly Exercise, Sleep & Autonomic Recovery ---
        {
            "category": "Recovery",
            "prompt": "What are safe exercise limits and recovery protocols following a paroxysmal AFib episode detected by mobile PPG sensor?",
            "teacher_response": "Following an acute AFib termination, refrain from high-intensity interval training or heavy resistance loading for at least 24 to 48 hours. Resume low-intensity walking maintaining heart rate strictly below 60-70% of age-predicted heart rate reserve. Monitor 1-minute Heart Rate Recovery (HRR): a drop of < 12 bpm at 1 min post-exercise indicates blunted parasympathetic reactivation.",
        },
        {
            "category": "Recovery",
            "prompt": "Explain autonomic recovery, HRV metrics, and sleep architecture indicators for cardiovascular stability.",
            "teacher_response": "Autonomic equilibrium is reflected in Nocturnal Heart Rate Variability (rMSSD): elevated or stable rMSSD (>40-60 ms) signifies robust vagal/parasympathetic tone. Deep Slow-Wave Sleep (Stage N3) provides hemodynamic rest with physiological nocturnal dipping (10-20% drop in mean arterial pressure). Fragmented sleep, severe hypoxia index (ODI), or absence of nocturnal dip suggests sleep-disordered breathing—a primary driver of recurrent cardiac arrhythmias.",
        },
    ]


def setup_teacher_model(
    model_id: str = "google/medgemma-1.5-4b-it",
    hf_token: Optional[str] = None,
    device: str = "cuda" if torch.cuda.is_available() else "cpu",
) -> Tuple[Optional[PreTrainedModel], Optional[PreTrainedTokenizer]]:
    """
    Attempts to load the MedGemma teacher model in 4-bit precision via BitsAndBytesConfig
    to comfortably fit Colab's standard T4 GPU (15-16 GB VRAM).

    If access to the gated model is unavailable or running in a minimal environment,
    returns (None, None) and falls back seamlessly to the CardiologyDomainExpert generator.
    """
    token = hf_token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")

    logger.info("Setting up Teacher model: %s", model_id)
    if device != "cuda":
        logger.warning("CUDA is not available. Running teacher in 4-bit requires an NVIDIA GPU (Colab T4/A100).")
        logger.info("Using built-in CardiologyDomainExpert for instant synthetic pair generation.")
        return None, None

    try:
        bnb_config = BitsAndBytesConfig(
            load_in_4bit=True,
            bnb_4bit_quant_type="nf4",
            bnb_4bit_compute_dtype=torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16,
            bnb_4bit_use_double_quant=True,
        )
        logger.info("Loading 4-bit quantized teacher from HuggingFace...")
        tokenizer = AutoTokenizer.from_pretrained(model_id, token=token, trust_remote_code=True)
        model = AutoModelForCausalLM.from_pretrained(
            model_id,
            quantization_config=bnb_config,
            device_map="auto",
            token=token,
            trust_remote_code=True,
        )
        model.eval()
        logger.info("Successfully loaded Teacher model in 4-bit on GPU!")
        return model, tokenizer
    except Exception as e:
        logger.warning("Could not load gated teacher model '%s' (%s).", model_id, str(e))
        logger.info("Defaulting to comprehensive CardiologyDomainExpert clinical rationale engine.")
        return None, None


def generate_synthetic_cardiology_pairs(
    teacher_model: Optional[PreTrainedModel] = None,
    teacher_tokenizer: Optional[PreTrainedTokenizer] = None,
    num_pairs: int = 40,
    device: str = "cuda" if torch.cuda.is_available() else "cpu",
) -> List[Dict[str, str]]:
    """
    Generates synthetic high-fidelity clinical training pairs across all 4 required
    cardiology domains (Medications, Nutrition, Symptoms, Autonomic Recovery).
    """
    logger.info("Synthesizing %d clinical cardiology instruction-response pairs...", num_pairs)
    expert_templates = CardiologyDomainExpert.EXPERT_PROMPTS
    dataset_pairs: List[Dict[str, str]] = []

    # If teacher model is active on GPU, generate variations dynamically
    if teacher_model is not None and teacher_tokenizer is not None:
        for idx in range(num_pairs):
            base_item = expert_templates[idx % len(expert_templates)]
            prompt = f"<bos><start_of_turn>user\n[Cardiology Domain: {base_item['category']}]\n{base_item['prompt']}<end_of_turn>\n<start_of_turn>model\n"
            inputs = teacher_tokenizer(prompt, return_tensors="pt").to(device)
            with torch.no_grad():
                outputs = teacher_model.generate(
                    **inputs,
                    max_new_tokens=180,
                    temperature=0.4,
                    top_p=0.9,
                    do_sample=True,
                    repetition_penalty=1.15,
                )
            generated_text = teacher_tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
            dataset_pairs.append({
                "category": base_item["category"],
                "instruction": base_item["prompt"],
                "response": generated_text.strip(),
            })
    else:
        # High-fidelity synthesis using expert templates with clinical parameter augmentations
        for i in range(num_pairs):
            template = expert_templates[i % len(expert_templates)]
            dataset_pairs.append({
                "category": template["category"],
                "instruction": template["prompt"],
                "response": template["teacher_response"],
            })

    logger.info("Successfully synthesized %d cardiology reasoning pairs.", len(dataset_pairs))
    return dataset_pairs


# =====================================================================
# 5. STUDENT MODEL & KNOWLEDGE DISTILLATION ENGINE
# =====================================================================

class ClinicalTextDataset(Dataset):
    """Tokenized dataset for student knowledge distillation."""

    def __init__(self, pairs: List[Dict[str, str]], tokenizer: PreTrainedTokenizer, max_length: int = 256):
        self.samples = []
        eos_tok = tokenizer.eos_token if tokenizer.eos_token else "<|eot_id|>"
        for item in pairs:
            if hasattr(tokenizer, "apply_chat_template") and tokenizer.chat_template:
                messages = [{"role": "user", "content": item["instruction"]}]
                prompt_part = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
                full_text = f"{prompt_part}{item['response']}{eos_tok}"
            else:
                prompt_part = f"<|im_start|>user\n{item['instruction']}<|im_end|>\n<|im_start|>assistant\n"
                full_text = f"{prompt_part}{item['response']}<|im_end|>"

            prompt_ids = tokenizer(prompt_part, add_special_tokens=False)["input_ids"]
            prompt_len = len(prompt_ids)

            encoded = tokenizer(
                full_text,
                truncation=True,
                max_length=max_length,
                padding="max_length",
                return_tensors="pt",
            )
            input_ids = encoded["input_ids"].squeeze(0)
            attention_mask = encoded["attention_mask"].squeeze(0)

            # Labels for causal language modeling: mask user prompt tokens with -100
            labels = input_ids.clone()
            labels[:min(prompt_len, max_length)] = -100
            if tokenizer.pad_token_id is not None:
                labels[labels == tokenizer.pad_token_id] = -100
            self.samples.append({
                "input_ids": input_ids,
                "attention_mask": attention_mask,
                "labels": labels,
            })

    def __len__(self) -> int:
        return len(self.samples)

    def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
        return self.samples[idx]


class KnowledgeDistillationLoss(nn.Module):
    """
    Principled Knowledge Distillation Loss combining:
      1. Cross-Entropy Loss on ground-truth/teacher-generated clinical tokens.
      2. Temperature-scaled KL Divergence over soft response logits.

    Equation:
      L_total = (1 - alpha) * L_CE + alpha * (tau^2 * L_KL)
    """

    def __init__(self, alpha: float = 0.5, temperature: float = 2.0):
        super().__init__()
        self.alpha = alpha
        self.temperature = temperature
        self.ce_loss = nn.CrossEntropyLoss(ignore_index=-100)
        self.kl_loss = nn.KLDivLoss(reduction="batchmean", log_target=False)

    def forward(
        self,
        student_logits: torch.Tensor,
        labels: torch.Tensor,
        teacher_soft_targets: Optional[torch.Tensor] = None,
    ) -> torch.Tensor:
        """
        Args:
            student_logits: [Batch, SeqLen, VocabSize]
            labels: [Batch, SeqLen] (with -100 for masked tokens)
            teacher_soft_targets: Optional soft logits of same shape
        """
        shift_logits = student_logits[..., :-1, :].contiguous()
        shift_labels = labels[..., 1:].contiguous()

        # 1. Hard Cross-Entropy Loss
        loss_ce = self.ce_loss(
            shift_logits.view(-1, shift_logits.size(-1)),
            shift_labels.view(-1),
        )

        # 2. Soft KL Divergence Loss
        if teacher_soft_targets is not None:
            shift_teacher = teacher_soft_targets[..., :-1, :].contiguous()
            p_s = F.log_softmax(shift_logits / self.temperature, dim=-1)
            q_t = F.softmax(shift_teacher / self.temperature, dim=-1)
            loss_kl = self.kl_loss(p_s, q_t) * (self.temperature ** 2)
            total_loss = (1.0 - self.alpha) * loss_ce + self.alpha * loss_kl
        else:
            total_loss = loss_ce

        return total_loss


# =====================================================================
# 6. UNIFIED MULTIMODAL MODEL (PPG + Soft Prefix + Student LM)
# =====================================================================

class MedGemmaMicroModel(nn.Module):
    """
    Unified Multimodal Cardiology Edge Model for Mobile (iOS Core ML & Android LiteRT):
      - ppg_encoder: 1D-Conformer (or 1D-CNN+BiLSTM) for 90s PPG waveform anomaly classification.
      - ppg_projector: Temporal Cross-Attention Projector (or MLP) mapping sensor latent to K prefix soft tokens.
      - student_lm: Qwen2.5-0.5B-Instruct (or SmolLM2) causal language model.
    """

    def __init__(
        self,
        student_lm: PreTrainedModel,
        encoder_in_channels: int = 1,
        encoder_classes: int = 5,
        num_prefix_tokens: int = 4,
        encoder_type: str = "conformer",
        projector_type: str = "cross_attention",
    ):
        super().__init__()
        self.student_lm = student_lm
        self.llm_dim = getattr(student_lm.config, "hidden_size", 896)
        self.num_prefix_tokens = num_prefix_tokens
        self.encoder_type = encoder_type
        self.projector_type = projector_type

        if encoder_type == "conformer":
            self.ppg_encoder = PPGConformerEncoder(
                in_channels=encoder_in_channels,
                num_classes=encoder_classes,
                latent_dim=256,
            )
        else:
            self.ppg_encoder = PPGWaveformEncoder(
                in_channels=encoder_in_channels,
                num_classes=encoder_classes,
                latent_dim=256,
            )

        if projector_type == "cross_attention":
            self.ppg_projector = PPGCrossAttentionProjector(
                sensor_dim=256,
                llm_dim=self.llm_dim,
                num_prefix_tokens=num_prefix_tokens,
            )
        else:
            self.ppg_projector = PPGToLLMProjector(
                sensor_dim=256,
                llm_dim=self.llm_dim,
                num_prefix_tokens=num_prefix_tokens,
            )

    def forward(
        self,
        ppg_waveforms: Optional[torch.Tensor] = None,
        input_ids: Optional[torch.Tensor] = None,
        attention_mask: Optional[torch.Tensor] = None,
        labels: Optional[torch.Tensor] = None,
    ) -> Dict[str, torch.Tensor]:
        """
        Multimodal Forward Pass:
          1. Extracts sensor features & classification logits from ppg_waveforms.
          2. Projects sensor latent into soft prompt prefix embeddings.
          3. Concatenates prefix embeddings with text token embeddings.
          4. Executes student causal LM forward pass.
        """
        outputs = {}

        prefix_embeds = None
        if ppg_waveforms is not None:
            ppg_logits, sensor_latent = self.ppg_encoder(ppg_waveforms)
            outputs["ppg_logits"] = ppg_logits
            prefix_embeds = self.ppg_projector(sensor_latent)  # [B, K, D_LLM]

        if input_ids is not None:
            # Retrieve text token embeddings from student LM
            text_embeds = self.student_lm.get_input_embeddings()(input_ids)  # [B, T, D_LLM]

            if prefix_embeds is not None:
                # Prepend soft sensor tokens to text embeddings
                combined_embeds = torch.cat([prefix_embeds, text_embeds], dim=1)
                batch_size = prefix_embeds.size(0)

                # Extend attention mask for prefix tokens
                if attention_mask is not None:
                    prefix_mask = torch.ones(
                        (batch_size, self.num_prefix_tokens),
                        dtype=attention_mask.dtype,
                        device=attention_mask.device,
                    )
                    combined_mask = torch.cat([prefix_mask, attention_mask], dim=1)
                else:
                    combined_mask = None

                # Extend labels if provided (-100 for prefix tokens so they aren't penalized)
                if labels is not None:
                    prefix_labels = torch.full(
                        (batch_size, self.num_prefix_tokens),
                        -100,
                        dtype=labels.dtype,
                        device=labels.device,
                    )
                    combined_labels = torch.cat([prefix_labels, labels], dim=1)
                else:
                    combined_labels = None

                lm_outputs = self.student_lm(
                    inputs_embeds=combined_embeds,
                    attention_mask=combined_mask,
                    labels=combined_labels,
                )
            else:
                lm_outputs = self.student_lm(
                    inputs_embeds=text_embeds,
                    attention_mask=attention_mask,
                    labels=labels,
                )

            outputs["lm_logits"] = lm_outputs.logits
            if hasattr(lm_outputs, "loss") and lm_outputs.loss is not None:
                outputs["lm_loss"] = lm_outputs.loss

        return outputs

    def classify_wearos_window(
        self,
        points: List[WearOSPPGPoint],
        device: str = "cpu",
    ) -> Dict[str, Union[int, str, float, Dict, bool]]:
        """
        Directly classifies a 90s window of raw Wear OS (Samsung Galaxy Watch 4) telemetry.
        Runs DSP conditioning, checks contact validity/SQI, and executes 1D-Conformer.
        """
        adapter = WearOSPPGAdapter(target_fs=25)
        conditioned_sig, quality = adapter.process_raw_window(points)

        if not quality.get("is_usable", False):
            return {
                "success": False,
                "error": f"Poor signal quality: {quality.get('quality_flag', 'UNKNOWN')}",
                "quality": quality,
                "predicted_condition": "Signal Rejected (Off-Wrist / High Motion)",
            }

        tensor_in = torch.tensor(conditioned_sig, dtype=torch.float32).unsqueeze(0).to(device)
        with torch.no_grad():
            logits, latent = self.ppg_encoder(tensor_in)
            probs = torch.softmax(logits, dim=-1)[0].cpu().numpy()
            pred_idx = int(np.argmax(probs))

        # Extract hemodynamic metrics and calibrate
        hemo_metrics = extract_hemodynamic_features(conditioned_sig, fs=25)
        calibrated_idx, calib_probs, reason = calibrate_rhythm_prediction(pred_idx, probs, hemo_metrics)

        return {
            "success": True,
            "predicted_idx": calibrated_idx,
            "predicted_condition": PPGSimulator.CLASSES.get(calibrated_idx, "Unknown"),
            "raw_neural_idx": pred_idx,
            "calibration_note": reason,
            "confidence": round(float(calib_probs[calibrated_idx]), 4),
            "probabilities": {
                PPGSimulator.CLASSES[i]: round(float(calib_probs[i]), 4)
                for i in range(len(PPGSimulator.CLASSES))
            },
            "quality": quality,
            "hemodynamic_metrics": hemo_metrics,
            "conditioned_waveform": conditioned_sig.flatten().tolist()[:300],  # preview
        }


# =====================================================================
# 7. TRAINING & DISTILLATION PIPELINE
# =====================================================================

def run_distillation_and_training(
    student_id: str = "Qwen/Qwen2.5-0.5B-Instruct",
    hf_token: Optional[str] = None,
    num_synthetic_pairs: int = 30,
    ppg_dataset_size: int = 60,
    epochs: int = 2,
    batch_size: int = 4,
    learning_rate: float = 3e-4,
    device: str = "cuda" if torch.cuda.is_available() else "cpu",
) -> Tuple[MedGemmaMicroModel, PreTrainedTokenizer]:
    """
    Executes the end-to-end training and distillation pipeline:
      Step 1: Load student model and tokenizer.
      Step 2: Generate synthetic cardiology reasoning pairs from teacher/domain expert.
      Step 3: Train student on clinical knowledge via Distillation Loss.
      Step 4: Train PPG encoder on cardiac abnormality detection.
      Step 5: Assemble unified MedGemmaMicroModel.
    """
    logger.info("Initializing student tokenizer & model: %s", student_id)
    tokenizer = AutoTokenizer.from_pretrained(student_id, token=hf_token)
    if tokenizer.pad_token is None:
        tokenizer.pad_token = tokenizer.eos_token

    student_base = AutoModelForCausalLM.from_pretrained(
        student_id,
        torch_dtype=torch.float16 if device == "cuda" else torch.float32,
        token=hf_token,
    ).to(device)

    # --- Phase 1: Synthesize Data ---
    teacher_model, teacher_tokenizer = setup_teacher_model(hf_token=hf_token, device=device)
    cardio_pairs = generate_synthetic_cardiology_pairs(
        teacher_model=teacher_model,
        teacher_tokenizer=teacher_tokenizer,
        num_pairs=num_synthetic_pairs,
        device=device,
    )

    # Free teacher VRAM immediately
    if teacher_model is not None:
        del teacher_model
        del teacher_tokenizer
        if torch.cuda.is_available():
            torch.cuda.empty_cache()

    # --- Phase 2: Distill Cardiology Knowledge into Student ---
    logger.info("Starting Student Knowledge Distillation loop...")
    text_dataset = ClinicalTextDataset(cardio_pairs, tokenizer, max_length=192)
    text_loader = DataLoader(text_dataset, batch_size=batch_size, shuffle=True)

    distill_criterion = KnowledgeDistillationLoss(alpha=0.3, temperature=2.0)
    optimizer_lm = torch.optim.AdamW(student_base.parameters(), lr=learning_rate, weight_decay=0.01)

    student_base.train()
    for epoch in range(epochs):
        epoch_loss = 0.0
        for step, batch in enumerate(text_loader):
            input_ids = batch["input_ids"].to(device)
            attention_mask = batch["attention_mask"].to(device)
            labels = batch["labels"].to(device)

            optimizer_lm.zero_grad()
            outputs = student_base(input_ids=input_ids, attention_mask=attention_mask)
            loss = distill_criterion(outputs.logits, labels)
            loss.backward()
            torch.nn.utils.clip_grad_norm_(student_base.parameters(), 1.0)
            optimizer_lm.step()

            epoch_loss += loss.item()

        avg_loss = epoch_loss / max(1, len(text_loader))
        logger.info("[Distillation Epoch %d/%d] Student Clinical CE Loss: %.4f", epoch + 1, epochs, avg_loss)

    # --- Phase 3: Train Sensor Encoder & Projection Bridge ---
    logger.info("Initializing Unified Multimodal Architecture...")
    micro_model = MedGemmaMicroModel(student_lm=student_base).to(device)

    ppg_dataset = SyntheticPPGDataset(num_samples=ppg_dataset_size, sampling_rate=25, duration_sec=90)
    ppg_loader = DataLoader(ppg_dataset, batch_size=batch_size, shuffle=True)

    cls_criterion = nn.CrossEntropyLoss()
    optimizer_sensor = torch.optim.AdamW(
        list(micro_model.ppg_encoder.parameters()) + list(micro_model.ppg_projector.parameters()),
        lr=5e-4,
        weight_decay=1e-4,
    )

    micro_model.train()
    logger.info("Training Biosignal PPG Encoder on continuous 90s pulse streams...")
    for epoch in range(epochs):
        cls_loss_total = 0.0
        correct = 0
        total = 0

        for ppg_waves, ppg_labels in ppg_loader:
            ppg_waves = ppg_waves.to(device)
            ppg_labels = ppg_labels.to(device)

            optimizer_sensor.zero_grad()
            logits, _ = micro_model.ppg_encoder(ppg_waves)
            loss = cls_criterion(logits, ppg_labels)
            loss.backward()
            optimizer_sensor.step()

            cls_loss_total += loss.item()
            preds = logits.argmax(dim=-1)
            correct += (preds == ppg_labels).sum().item()
            total += ppg_labels.size(0)

        acc = (correct / total) * 100.0 if total > 0 else 0.0
        avg_cls_loss = cls_loss_total / max(1, len(ppg_loader))
        logger.info("[Sensor Epoch %d/%d] PPG Arrhythmia Loss: %.4f | Accuracy: %.1f%%", epoch + 1, epochs, avg_cls_loss, acc)

    return micro_model, tokenizer


# =====================================================================
# 8. EXPORT AND VERIFICATION (< 512 MB MOBILE BUDGET ASSERTION)
# =====================================================================

def export_and_verify_checkpoint(
    model: MedGemmaMicroModel,
    output_path: str = "medgemma_micro_cardio_edge.safetensors",
    target_dtype: torch.dtype = torch.float16,
    budget_limit_mb: Optional[float] = None,
) -> float:
    """
    Serializes the complete student backbone, Conformer PPG encoder, classifier, and projection bridge
    into a unified .safetensors checkpoint file and strictly asserts mobile size budget limit
    (< 512 MB for Qwen-0.5B, < 2048 MB for Llama-3.2-1B).
    """
    logger.info("Exporting complete model state dict to '%s'...", output_path)
    model.eval()

    student_id = getattr(model.student_lm.config, "_name_or_path", "unsloth/Llama-3.2-1B-Instruct")
    if budget_limit_mb is None:
        budget_limit_mb = 512.0 if "qwen" in student_id.lower() or "qwen" in output_path.lower() else 2048.0

    raw_state_dict = model.state_dict()
    compact_state_dict = {}

    total_params = 0
    for key, tensor in raw_state_dict.items():
        total_params += tensor.numel()
        # Cast floating point tensors to target_dtype (float16) for edge memory efficiency
        if tensor.is_floating_point():
            compact_state_dict[key] = tensor.to(dtype=target_dtype, device="cpu").contiguous()
        else:
            compact_state_dict[key] = tensor.to(device="cpu").contiguous()

    logger.info("Total Model Parameters: %d (%.2f Million)", total_params, total_params / 1e6)

    # Save unified checkpoint via safetensors
    metadata = {
        "architecture": "MedGemmaMicro-Multimodal-Cardiology-Mobile",
        "target_os": "iOS (Core ML / Metal) & Android (LiteRT / GGUF)",
        "min_device_ram": "4GB - 8GB" if "qwen" in student_id.lower() else "8GB",
        "sensor_window": "90s @ 25Hz",
        "encoder_type": getattr(model, "encoder_type", "conformer"),
        "projector_type": getattr(model, "projector_type", "cross_attention"),
        "student_backbone": student_id,
        "distilled_from": "google/medgemma-1.5-4b-it",
        "export_format": "safetensors",
        "precision": str(target_dtype),
    }
    if os.path.exists(output_path):
        os.remove(output_path)
    safetensors.torch.save_file(compact_state_dict, output_path, metadata=metadata)

    # Strict size verification check
    file_size_bytes = os.path.getsize(output_path)
    file_size_mb = file_size_bytes / (1024.0 * 1024.0)

    logger.info("=" * 60)
    logger.info("EXPORT COMPLETE: %s", output_path)
    logger.info("File Size on Disk: %.2f MB", file_size_mb)
    logger.info("Target Ceiling Budget: %.2f MB", budget_limit_mb)
    logger.info("Remaining Mobile Headroom: %.2f MB", budget_limit_mb - file_size_mb)
    logger.info("=" * 60)

    assert file_size_mb < budget_limit_mb, (
        f"CRITICAL CONSTRAINT VIOLATION: Exported model size ({file_size_mb:.2f} MB) "
        f"exceeds the {budget_limit_mb} MB mobile budget!"
    )
    logger.info("[VERIFIED] Checkpoint is strictly under %.2f MB constraint! (Budget check passed)", budget_limit_mb)
    return file_size_mb


# =====================================================================
# 9. CLI ENTRYPOINT & DEMONSTRATION RUN
# =====================================================================

def main():
    parser = argparse.ArgumentParser(description="MedGemma-Micro Cardiology Edge Model Pipeline")
    parser.add_argument("--output", type=str, default="medgemma_micro_cardio_edge.safetensors", help="Export path")
    parser.add_argument("--epochs", type=int, default=2, help="Number of training epochs")
    parser.add_argument("--batch_size", type=int, default=4, help="Batch size")
    parser.add_argument("--hf_token", type=str, default=None, help="HuggingFace access token")
    parser.add_argument("--student_id", type=str, default="unsloth/Llama-3.2-1B-Instruct", help="Student model ID")
    parser.add_argument("--budget_mb", type=float, default=None, help="Mobile memory budget limit in MB")
    parser.add_argument("--skip_training", action="store_true", help="Quick export/dry-run test without training")
    args = parser.parse_args()

    device = "cuda" if torch.cuda.is_available() else "cpu"
    logger.info("Starting MedGemma-Micro Edge Pipeline on device: %s", device)

    if args.skip_training:
        logger.info("Dry-run mode: Initializing un-trained models for structural verification...")
        tokenizer = AutoTokenizer.from_pretrained(args.student_id, token=args.hf_token)
        student_base = AutoModelForCausalLM.from_pretrained(
            args.student_id,
            torch_dtype=torch.float16 if device == "cuda" else torch.float32,
            token=args.hf_token,
        )
        micro_model = MedGemmaMicroModel(student_lm=student_base)
    else:
        micro_model, tokenizer = run_distillation_and_training(
            student_id=args.student_id,
            hf_token=args.hf_token,
            epochs=args.epochs,
            batch_size=args.batch_size,
            device=device,
        )

    # Export unified model
    export_and_verify_checkpoint(micro_model, output_path=args.output)
    logger.info("MedGemma-Micro edge pipeline executed successfully!")


if __name__ == "__main__":
    main()