File size: 22,626 Bytes
b72d311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
hsFAST ML Service β€” FastAPI
Serves the ProtStabCNN model (pre-trained on DMSv4, 455k sequences).

Endpoints:
  POST /predict          - predict Ξ”G for a single protein sequence
  POST /predict/batch    - predict Ξ”G for up to 100 sequences
  GET  /predict/quick    - quick GET for browser testing
  GET  /health           - liveness + model status
  GET  /model/info       - architecture + training metadata
  GET  /dataset/stats    - training dataset statistics (for Dataset Explorer UI)
  POST /train            - trigger retraining (Phase G β€” requires dataset import)
"""

import hashlib
import os
import sys
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import List, Optional

import torch
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

# ── Config ────────────────────────────────────────────────────────────────────

MODELS_DIR     = Path(__file__).parent / "models"
# ML_CHECKPOINT_PATH lets a specific checkpoint (e.g. the experimental gated
# model) be loaded for local testing without touching the deployed default.
CHECKPOINT     = Path(os.environ.get("ML_CHECKPOINT_PATH") or (MODELS_DIR / "best_model.pt"))
DEVICE         = "cuda" if torch.cuda.is_available() else "cpu"
VALID_AAS      = set("ACDEFGHIKLMNPQRSTVWYX")

_model  = None   # loaded at startup
_meta   = {}     # checkpoint metadata (model_type, model_name, val_metrics)
_family = "cnn"  # 'cnn' | 'esm2_lora' | 'esm2_gated' β€” see protstab_predict._detect_family


_SCALAR_VAL_METRIC_KEYS = ("mae", "rmse", "pearson_r", "spearman_rho", "accuracy")


def _clean_val_metrics(val_metrics):
    """Drop non-scalar entries (e.g. raw preds/targets arrays some checkpoints
    embed) that aren't JSON-serializable and are too large to return anyway."""
    if not isinstance(val_metrics, dict):
        return val_metrics
    return {k: v for k, v in val_metrics.items() if k in _SCALAR_VAL_METRIC_KEYS}


def _read_meta() -> dict:
    """Read lightweight metadata from the checkpoint without keeping it in memory."""
    try:
        ckpt = torch.load(str(CHECKPOINT), map_location="cpu", weights_only=False)
        if isinstance(ckpt, dict):
            meta = {k: ckpt[k] for k in ("model_type", "model_name", "epoch", "val_metrics") if k in ckpt}
            if "val_metrics" in meta:
                meta["val_metrics"] = _clean_val_metrics(meta["val_metrics"])
            return meta
    except Exception:
        pass
    return {}


def _active_model_name() -> str:
    """Real name of the loaded model β€” stored by the backend as modelVersion."""
    if _family == "esm2_gated":
        return _meta.get("model_name", "esm2_t30_150M_lora_gated")
    if _family == "esm2_lora":
        return _meta.get("model_name", "esm2_t12_35M_lora")
    return "protstab_cnn_v0"


# ── Lifespan (startup / shutdown) ─────────────────────────────────────────────

@asynccontextmanager
async def lifespan(app: FastAPI):
    global _model, _meta, _family
    try:
        from protstab_predict import load_model, _detect_family
        _meta  = _read_meta()
        ckpt_for_detect = torch.load(str(CHECKPOINT), map_location="cpu", weights_only=False)
        _family = _detect_family(ckpt_for_detect)
        del ckpt_for_detect
        _model = load_model(str(CHECKPOINT), DEVICE)
        kind = _family
        print(f"[ml-service] Model loaded        : {_model.__class__.__name__} ({kind})")
        print(f"[ml-service] Checkpoint          : {CHECKPOINT}")
        print(f"[ml-service] Device              : {DEVICE}")
        print(f"[ml-service] Trainable params    : {_model.count_parameters():,}")
        if _meta.get("val_metrics"):
            print(f"[ml-service] Val metrics         : {_meta['val_metrics']}")
    except Exception as e:
        print(f"[ml-service] WARNING: could not load model β€” {e}")
        _model = None
    yield
    _model = None


# ── App ───────────────────────────────────────────────────────────────────────

app = FastAPI(
    title="hsFAST ML Service",
    description="Protein thermodynamic stability (Ξ”G) prediction β€” ProtStabCNN v0",
    version="2.0.0",
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173", "http://localhost:4000", "*"],
    allow_methods=["*"],
    allow_headers=["*"],
)


# ── Schemas ───────────────────────────────────────────────────────────────────

class PredictRequest(BaseModel):
    # Accept both "seq" (client's original API) and "sequence" (our legacy field)
    seq:          Optional[str] = None
    sequence:     Optional[str] = None
    model_name:   str           = "protstab_cnn_v0"
    # Legacy fields from old API β€” accepted but ignored by CNN
    conditions:   dict          = {}
    tier:         str           = "GOLD"
    predictionId: str           = ""


class PredictResponse(BaseModel):
    dg:          float
    stability:   str
    seq_len:     int
    truncated:   bool
    model_name:  str
    device:      str
    latency_ms:  float


class BatchItem(BaseModel):
    id:  str
    seq: str


class BatchRequest(BaseModel):
    sequences:  list[BatchItem]
    model_name: str = "protstab_cnn_v0"


class BatchResultItem(BaseModel):
    id:        str
    dg:        Optional[float]
    stability: Optional[str]
    seq_len:   Optional[int]
    error:     Optional[str]


class BatchResponse(BaseModel):
    results:    list[BatchResultItem]
    model_name: str
    device:     str
    latency_ms: float


# ── Helpers ───────────────────────────────────────────────────────────────────

def _active_max_aa() -> int:
    """Residue cap of the loaded model. ESM2-LoRA r16 was trained at 80 aa;
    ESM2-gated r32 placeholder is 512 (UNCONFIRMED, see esm2_gated_model.py); CNN uses 256."""
    if _family == "esm2_gated":
        from esm2_gated_model import MAX_LEN as GATED_MAX
        return GATED_MAX
    if _family == "esm2_lora":
        from esm2_lora_model import MAX_LEN as ESM2_MAX
        return ESM2_MAX
    from protstab_model import MAX_LEN as CNN_MAX
    return CNN_MAX


def _clean_seq(raw: str) -> tuple[str, bool]:
    """Strip FASTA headers, whitespace, uppercase. Returns (seq, truncated)."""
    max_aa = _active_max_aa()
    seq = raw
    seq = "\n".join(l for l in seq.splitlines() if not l.startswith(">"))
    seq = seq.upper().replace(" ", "").replace("\n", "").replace("\r", "")
    truncated = len(seq) > max_aa
    return seq[:max_aa], truncated


def _require_model():
    if _model is None:
        raise HTTPException(503, "Model not loaded. Check ml-service startup logs.")


# ── Routes ────────────────────────────────────────────────────────────────────

@app.get("/health")
def health():
    return {
        "status":             "ok",
        "model_loaded":       _model is not None,
        "checkpoint_exists":  CHECKPOINT.exists(),
        "device":             DEVICE,
        "service":            "hsFAST ML Service v2.0",
    }


@app.get("/model/info")
def model_info():
    _require_model()
    if _family == "esm2_gated":
        return {
            "name":          _meta.get("model_name", "esm2_t30_150M_lora_gated"),
            "model_type":    "esm2_gated",
            "architecture":  "ESM2-150M (facebook/esm2_t30_150M_UR50D) + LoRA r=32 on "
                             "q/k/v/dense, masked-mean pool β†’ Linear(640β†’64) gated by "
                             "temperature/pH β†’ MLP(64β†’32β†’1)",
            "parameters":    _model.count_parameters(),
            "max_len":       _active_max_aa(),
            "usesConditions": True,
            "input":         f"tokenized protein sequence (first {_active_max_aa()} aa, "
                             "UNCONFIRMED truncation length) + temperature/pH conditions",
            "output":        "Ξ”G (kcal/mol) β€” more negative = more stable (platform convention)",
            "training_data": "author-supplied (not yet documented in this repo)",
            "val_metrics":   _meta.get("val_metrics"),
            "epoch":         _meta.get("epoch"),
            "phase":         "EXPERIMENTAL β€” env-conditioned model, not yet verified "
                             "(see esm2_gated_model.py for open questions)",
        }
    if _family == "esm2_lora":
        return {
            "name":          _meta.get("model_name", "esm2_t12_35M_lora"),
            "model_type":    "esm2_lora",
            "architecture":  "ESM2-35M (facebook/esm2_t12_35M_UR50D) + LoRA r=16 on q/k/v, "
                             "masked-mean pool β†’ LayerNorm β†’ MLP(480β†’256β†’64β†’1)",
            "parameters":    _model.count_parameters(),       # trainable (LoRA + head)
            "max_len":       _active_max_aa(),
            "usesConditions": False,
            "input":         "tokenized protein sequence, first 80 aa (small-domain scope)",
            "output":        "Ξ”G (kcal/mol) β€” more negative = more stable (platform convention)",
            "training_data": "~3.3M small-domain sequences (DMSv4/v5/v7 + Megascale DMS + MGnify)",
            "val_metrics":   _meta.get("val_metrics"),
            "epoch":         _meta.get("epoch"),
            "phase":         "ESM2-35M LoRA r16 fine-tune",
        }
    return {
        "name":          "protstab_cnn_v0",
        "model_type":    "cnn",
        "architecture":  "1D CNN β€” 3 ConvBlocks (21β†’64β†’128β†’256, k=5/5/3) + GlobalAvgPool + MLP(256β†’128β†’32β†’1)",
        "parameters":    _model.count_parameters(),
        "max_len":       _active_max_aa(),
        "usesConditions": False,
        "input":         "one-hot protein sequence, max 256 aa",
        "output":        "Ξ”G (kcal/mol) β€” positive = stable, negative = unstable",
        "training_data": "DMSv4 filtered (455,589 sequences)",
        "phase":         "Phase 1 prototype β€” ESM2-35M fine-tune planned for Phase 2",
    }


@app.post("/predict", response_model=PredictResponse)
def predict(req: PredictRequest):
    _require_model()
    from protstab_predict import predict_one, stability_label

    raw = req.seq or req.sequence or ""
    if not raw.strip():
        raise HTTPException(400, "Provide 'seq' or 'sequence' field with an amino acid sequence")

    seq, truncated = _clean_seq(raw)
    if len(seq) < 10:
        raise HTTPException(400, "Sequence too short (minimum 10 amino acids)")

    bad = set(seq) - VALID_AAS
    if bad:
        raise HTTPException(400, f"Invalid amino acid characters: {sorted(bad)}")

    t0  = time.perf_counter()
    # Client convention: NEGATIVE Ξ”G = more stable. The model is trained on dmsv4
    # `deltaG` (positive = more stable), so we negate at the API boundary so every
    # downstream consumer (DB, CSV, dashboard, chat) is consistent. Displayed Ξ”G
    # therefore equals -(dmsv4 deltaG). Assumed to also hold for esm2_gated β€”
    # unverified, see esm2_gated_model.py.
    dg  = round(-predict_one(seq, _model, DEVICE, conditions=req.conditions), 4)
    ms  = round((time.perf_counter() - t0) * 1000, 2)

    return PredictResponse(
        dg=dg,
        stability=stability_label(dg),
        seq_len=len(seq),
        truncated=truncated,
        model_name=_active_model_name(),
        device=DEVICE,
        latency_ms=ms,
    )


@app.post("/predict/batch", response_model=BatchResponse)
def predict_batch_endpoint(req: BatchRequest):
    if len(req.sequences) > 100:
        raise HTTPException(400, "Maximum 100 sequences per batch request")
    _require_model()
    from protstab_predict import predict_one, stability_label

    t0      = time.perf_counter()
    results = []

    for item in req.sequences:
        try:
            seq, _ = _clean_seq(item.seq)
            if len(seq) < 10:
                raise ValueError(f"Sequence too short ({len(seq)} aa, minimum 10)")
            bad = set(seq) - VALID_AAS
            if bad:
                raise ValueError(f"Invalid characters: {sorted(bad)}")
            dg = round(-predict_one(seq, _model, DEVICE), 4)  # negate: negative Ξ”G = more stable
            results.append(BatchResultItem(
                id=item.id, dg=dg, stability=stability_label(dg),
                seq_len=len(seq), error=None,
            ))
        except Exception as e:
            results.append(BatchResultItem(
                id=item.id, dg=None, stability=None, seq_len=None, error=str(e),
            ))

    ms = round((time.perf_counter() - t0) * 1000, 2)
    return BatchResponse(
        results=results, model_name=_active_model_name(), device=DEVICE, latency_ms=ms,
    )


@app.get("/predict/quick")
def predict_quick(seq: str = Query(..., description="Amino acid sequence")):
    """Quick GET endpoint for browser/curl testing."""
    _require_model()
    from protstab_predict import predict_one, stability_label

    seq_clean, truncated = _clean_seq(seq)
    if len(seq_clean) < 10:
        raise HTTPException(400, "Sequence too short (minimum 10 amino acids)")
    bad = set(seq_clean) - VALID_AAS
    if bad:
        raise HTTPException(400, f"Invalid characters: {sorted(bad)}")

    t0 = time.perf_counter()
    dg = round(-predict_one(seq_clean, _model, DEVICE), 4)  # negate: negative Ξ”G = more stable
    ms = round((time.perf_counter() - t0) * 1000, 2)
    return {
        "seq": seq_clean, "dg": dg, "stability": stability_label(dg),
        "seq_len": len(seq_clean), "truncated": truncated, "latency_ms": ms,
    }


# ── Residue-level stabilizing-mutation scan ──────────────────────────────────
# Given a sequence, score every position Γ— substitution and rank by ΔΔG.
# Convention (client): more negative Ξ”G = more stable β†’ NEGATIVE ΔΔG = STABILISING.
#
# NOTE (Phase 0, 2026-07): per client direction, the suggestion list + confidence
# scores are a FAST HEURISTIC placeholder β€” they drive the demo GUI but are NOT yet
# data-backed. This replaces the previous per-mutant ESM2 forward-pass scan, which
# was correct-in-spirit but ran hundreds of inferences per request (minutes on a
# free CPU). The data-backed residue model returns in Phase 3 (see _heuristic_ddg).

AA20 = "ACDEFGHIKLMNPQRSTVWY"

# Placeholder residue "stability propensity" (GUI demo only, NOT data-backed).
# Higher = tends to favour a well-packed/stable fold. Blends hydrophobicity and
# secondary-structure/turn propensity so synthesized ΔΔGs look plausible.
_STAB_PROPENSITY = {
    'A': 0.4, 'C': 0.6, 'D': -0.3, 'E': -0.1, 'F': 0.7, 'G': -0.6, 'H': 0.1,
    'I': 0.8, 'K': -0.2, 'L': 0.8, 'M': 0.5, 'N': -0.3, 'P': -0.7, 'Q': -0.1,
    'R': 0.2, 'S': -0.2, 'T': 0.0, 'V': 0.7, 'W': 0.6, 'Y': 0.5,
}


def _seeded_unit(key: str) -> float:
    """Deterministic pseudo-random in [0,1) from a string key (stable across runs)."""
    return int(hashlib.md5(key.encode()).hexdigest()[:8], 16) / 0xFFFFFFFF


def _heuristic_ddg(pos: int, wt_aa: str, aa: str) -> float:
    """Placeholder ΔΔG (kcal/mol). Negative = stabilising. Deterministic per mutation."""
    base = _STAB_PROPENSITY.get(wt_aa, 0.0) - _STAB_PROPENSITY.get(aa, 0.0)
    jitter = (_seeded_unit(f"d{pos}{wt_aa}{aa}") - 0.5) * 1.6
    return round(base * 1.1 + jitter, 4)


def _heuristic_conf(ddg: float, pos: int, wt_aa: str, aa: str) -> float:
    """Placeholder confidence in [0.50, 0.95]; larger |ΔΔG| β†’ higher confidence."""
    mag = min(abs(ddg) / 3.0, 1.0)
    j = (_seeded_unit(f"c{pos}{wt_aa}{aa}") - 0.5) * 0.14
    return round(min(0.95, max(0.50, 0.58 + 0.32 * mag + j)), 2)


class SuggestRequest(BaseModel):
    seq:          Optional[str]        = None
    sequence:     Optional[str]        = None
    top_k:        int                  = 50
    positions:    Optional[List[int]]  = None   # 1-indexed positions to scan; None = all
    conditions:   dict                 = {}     # only used by the esm2_gated model
    predictionId: str                  = ""


@app.post("/suggest")
def suggest(req: SuggestRequest):
    _require_model()
    from protstab_predict import predict_one

    raw = req.seq or req.sequence or ""
    if not raw.strip():
        raise HTTPException(400, "Provide 'seq' or 'sequence' with an amino acid sequence")

    seq, truncated = _clean_seq(raw)
    if len(seq) < 10:
        raise HTTPException(400, "Sequence too short (minimum 10 amino acids)")
    bad = set(seq) - VALID_AAS
    if bad:
        raise HTTPException(400, f"Invalid amino acid characters: {sorted(bad)}")

    t0 = time.perf_counter()
    wt_dg = round(-predict_one(seq, _model, DEVICE, conditions=req.conditions), 4)   # real Ξ”G baseline, negated

    # Positions to scan: honour the client's include/exclude selection (1-indexed).
    if req.positions:
        scan_positions = sorted({p for p in req.positions if 1 <= p <= len(seq)})
    else:
        scan_positions = list(range(1, len(seq) + 1))

    # Score every substitution at each selected position (fast heuristic β€” see note).
    candidates = []
    for pos in scan_positions:
        wt_aa = seq[pos - 1]
        if wt_aa not in AA20:
            continue
        for aa in AA20:
            if aa == wt_aa:
                continue
            ddg = _heuristic_ddg(pos, wt_aa, aa)
            candidates.append({
                "position":      pos,
                "originalAa":    wt_aa,
                "substitutedAa": aa,
                "mutation":      f"{wt_aa}{pos}{aa}",
                "dg":            round(wt_dg + ddg, 4),
                "ddG":           ddg,
                "confidence":    _heuristic_conf(ddg, pos, wt_aa, aa),
            })

    candidates.sort(key=lambda c: c["ddG"])   # most stabilising first
    for r, c in enumerate(candidates, 1):
        c["rank"] = r

    # Per-position hotspot map
    by_pos = {}
    for c in candidates:
        by_pos.setdefault(c["position"], []).append(c)
    strongest = min((c["ddG"] for c in candidates), default=-1e-9)
    hotspots = []
    for pos, lst in by_pos.items():
        best = min(c["ddG"] for c in lst)
        sp = round(best / strongest, 3) if (best < 0 and strongest < 0) else 0.0
        tol = round(sum(1 for c in lst if c["ddG"] <= 0.5) / len(lst), 3)
        hotspots.append({
            "position":               pos,
            "residue":                lst[0]["originalAa"],
            "stabilizationPotential": min(1.0, sp),
            "mutationalTolerance":    tol,
        })
    hotspots.sort(key=lambda h: h["position"])

    ms = round((time.perf_counter() - t0) * 1000, 2)
    return {
        "wt_dg":      wt_dg,
        "seq_len":    len(seq),
        "truncated":  truncated,
        "n_scanned":  len(candidates),
        "model_name": _active_model_name(),
        "candidates": candidates[:max(1, req.top_k)],
        "hotspotMap": hotspots,
        "latency_ms": ms,
    }


@app.get("/dataset/stats")
def dataset_stats():
    """Training dataset statistics β€” used by Dataset Explorer UI."""
    return {
        "modelVersion":    _active_model_name(),
        "architecture":    "ESM2-35M + LoRA r=16 (masked-mean pool + MLP head)",
        "parameters":      _model.count_parameters() if _model else None,
        "nTrainingSeqs":   3300000,
        "splits": {
            "train": 3200000,
            "val":   817,
            "test":  3282,
        },
        "dgStats": {
            "mean": 1.815,
            "std":  3.10,
            "min":  -19.0,
            "max":  17.0,
        },
        "valMetrics": {
            "mae":         (_meta.get("val_metrics") or {}).get("mae"),
            "rmse":        (_meta.get("val_metrics") or {}).get("rmse"),
            "pearsonR":    (_meta.get("val_metrics") or {}).get("pearson_r"),
            "spearmanRho": (_meta.get("val_metrics") or {}).get("spearman_rho"),
            "note": "Validation metrics from training checkpoint" if _meta.get("val_metrics")
                    else "Run POST /train to evaluate on val split",
        },
        "trainingData":  "~3.3M small-domain sequences (DMSv4/v5/v7 + Megascale DMS + MGnify), K50 β†’ Ξ”G",
        "phase":         "ESM2-35M LoRA r16 fine-tune",
        "modelLoaded":   _model is not None,
        "checkpointPath": str(CHECKPOINT),
    }


@app.post("/train")
def train_model(req: dict = {}):
    """
    Trigger retraining of ProtStabCNN.
    Phase G will wire this to the imported DMSv4 dataset in MongoDB.
    For now, returns training instructions.
    """
    data_path = Path(__file__).parent / "data" / "dmsv4_filtered_train_splits.csv"
    if not data_path.exists():
        return {
            "status": "dataset_missing",
            "message": "Phase G dataset import required first.",
            "instructions": (
                "Import dmsv4_filtered_train_splits.csv into ml-service/data/ "
                "then POST /train to retrain the CNN."
            ),
            "checkpoint_exists": CHECKPOINT.exists(),
        }

    # Dataset is present β€” run train.py from client's repo
    import subprocess
    train_script = Path(__file__).parent.parent.parent / "19411306" / "ml" / "train.py"
    if not train_script.exists():
        return {"status": "error", "message": f"Train script not found at {train_script}"}

    try:
        result = subprocess.run(
            [sys.executable, str(train_script),
             "--data", str(data_path), "--epochs", "10", "--limit", "50000"],
            capture_output=True, text=True, timeout=600,
        )
        return {
            "status":     "trained" if result.returncode == 0 else "error",
            "stdout":     result.stdout[-2000:],
            "stderr":     result.stderr[-1000:],
            "returncode": result.returncode,
        }
    except subprocess.TimeoutExpired:
        raise HTTPException(504, "Training timed out")