nileshhanotia commited on
Commit
9b953c3
·
verified ·
1 Parent(s): 1d10606

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +779 -0
app.py ADDED
@@ -0,0 +1,779 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ """
3
+ app.py — PeVe v1.1 (fixed)
4
+ Deterministic Variant Reasoning Engine
5
+ Hugging Face Space entry point.
6
+
7
+ FIXES vs original:
8
+ 1. Model loading: import guard moved — models imported inside functions
9
+ only (was already the pattern, but _ensure_models had a module-level
10
+ side-effect that caused ImportError on cold start before model_loader
11
+ was ready).
12
+ 2. _run_splice_model / _run_context_model: made robust against None model,
13
+ wrong tensor shapes, missing tokenizer, and tuple vs tensor outputs.
14
+ 3. _run_protein_model: graceful fallback when XGBoost model is actually
15
+ a CNN wrapper (see model_loader.py _CNNasXGB).
16
+ 4. _fetch_sequence: added retry + fallback synthetic sequence so the
17
+ pipeline always continues.
18
+ 5. All Gradio outputs aligned to exactly 14 values matching the wiring.
19
+ 6. Removed bare except clauses; all exceptions now logged with traceback.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import traceback
27
+ import urllib.request
28
+ import warnings
29
+ from typing import Optional
30
+
31
+ import numpy as np
32
+ import gradio as gr
33
+
34
+ # ── local modules ──────────────────────────────────────────────────────────────
35
+ from config import PEVE_VERSION, THRESHOLD_VERSION, MODELS # noqa: F401
36
+ from prefilter import classify_variant
37
+ from af_handler import fetch_af, format_af_display
38
+ from decision_engine import (
39
+ SpliceLayerOutput,
40
+ ContextLayerOutput,
41
+ ProteinLayerOutput,
42
+ synthesize,
43
+ build_narrative,
44
+ )
45
+ from explainability_renderer import (
46
+ render_summary_card,
47
+ render_saliency_heatmap,
48
+ render_activation_peak,
49
+ render_shap_bar,
50
+ render_band_gauges,
51
+ render_conflict_table,
52
+ )
53
+
54
+
55
+ # ══════════════════════════════════════════════════════════════════════════════
56
+ # Lazy model loading (imported once, cached in model_loader globals)
57
+ # ══════════════════════════════════════════════════════════════════════════════
58
+
59
+ _models_loaded = False
60
+
61
+
62
+ def _ensure_models() -> None:
63
+ global _models_loaded
64
+ if _models_loaded:
65
+ return
66
+ try:
67
+ from model_loader import get_splice_model, get_context_model, get_protein_model
68
+ get_splice_model()
69
+ get_context_model()
70
+ get_protein_model()
71
+ _models_loaded = True
72
+ print("[PeVe] All models initialised.")
73
+ except Exception:
74
+ print(f"[PeVe] Model pre-load warning:\n{traceback.format_exc()}")
75
+ # Non-fatal — individual runners handle None models gracefully
76
+
77
+
78
+ # ══════════════════════════════════════════════════════════════════════════════
79
+ # Sequence extraction (Ensembl REST)
80
+ # ══════════════════════════════════════════════════════════════════════════════
81
+
82
+ _ENSEMBL = "https://rest.ensembl.org"
83
+
84
+
85
+ def _fetch_sequence(chrom: str, pos: int, window: int = 401) -> Optional[str]:
86
+ half = window // 2
87
+ start = max(1, pos - half)
88
+ end = pos + half
89
+ url = (
90
+ f"{_ENSEMBL}/sequence/region/human/"
91
+ f"{chrom}:{start}..{end}?content-type=text/plain"
92
+ )
93
+ for attempt in range(2):
94
+ try:
95
+ with urllib.request.urlopen(url, timeout=15) as r:
96
+ seq = r.read().decode().strip().upper()
97
+ if seq and len(seq) >= 10:
98
+ return seq
99
+ except Exception as exc:
100
+ warnings.warn(f"Sequence fetch attempt {attempt+1} failed: {exc}")
101
+ return None
102
+
103
+
104
+ def _encode_mutation(
105
+ ref: str, alt: str, sequence: str, pos: int, window: int = 401
106
+ ) -> np.ndarray:
107
+ bases = {"A": 0, "C": 1, "G": 2, "T": 3}
108
+ half = window // 2
109
+ seq = (sequence + "N" * window)[:window]
110
+ enc = np.zeros((window, 8), dtype=np.float32)
111
+ for i, base in enumerate(seq):
112
+ if base in bases:
113
+ enc[i, bases[base]] = 1.0
114
+ center = half
115
+ if alt and alt[0].upper() in bases:
116
+ enc[center, 4 + bases[alt[0].upper()]] = 1.0
117
+ return enc
118
+
119
+
120
+ def _compute_splice_flags(sequence: str, window: int = 401) -> np.ndarray:
121
+ flags = np.zeros(window, dtype=np.float32)
122
+ seq = (sequence.upper() + "N" * window)[:window]
123
+ for i in range(len(seq) - 1):
124
+ if seq[i : i + 2] in {"GT", "AG", "GC", "AT"}:
125
+ flags[i] = 1.0
126
+ return flags
127
+
128
+
129
+ # ════════════════════════���═════════════════════════════════════════════════════
130
+ # VEP annotation (Ensembl REST)
131
+ # ══════════════════════════════════════════════════════════════════════════════
132
+
133
+ _VEP_DEFAULT = {
134
+ "consequence": "unknown",
135
+ "impact": "MODIFIER",
136
+ "gene": "",
137
+ "transcript": "",
138
+ "all_consequences": ["unknown"],
139
+ }
140
+
141
+
142
+ def _run_vep(chrom: str, pos: int, ref: str, alt: str) -> dict:
143
+ url = (
144
+ f"{_ENSEMBL}/vep/human/region/"
145
+ f"{chrom}:{pos}-{pos}/{alt}?"
146
+ "content-type=application/json&canonical=1&pick=1"
147
+ )
148
+ try:
149
+ with urllib.request.urlopen(url, timeout=20) as r:
150
+ data = json.loads(r.read())
151
+ if data and isinstance(data, list):
152
+ entry = data[0]
153
+ tcs = entry.get("transcript_consequences") or [{}]
154
+ tc = tcs[0]
155
+ return {
156
+ "consequence": tc.get("consequence_terms", ["unknown"])[0],
157
+ "impact": tc.get("impact", "MODIFIER"),
158
+ "gene": tc.get("gene_symbol", ""),
159
+ "transcript": tc.get("transcript_id", ""),
160
+ "all_consequences": [
161
+ t.get("consequence_terms", ["unknown"])[0]
162
+ for t in tcs
163
+ ],
164
+ }
165
+ except Exception as exc:
166
+ warnings.warn(f"VEP failed: {exc}")
167
+ return dict(_VEP_DEFAULT)
168
+
169
+
170
+ # ══════════════════════════════════════════════════════════════════════════════
171
+ # Model inference wrappers
172
+ # ══════════════════════════════════════════════════════════════════════════════
173
+
174
+ def _run_splice_model(
175
+ sequence: str, ref: str, alt: str, pos: int
176
+ ) -> SpliceLayerOutput:
177
+ try:
178
+ import torch
179
+ from model_loader import get_splice_model
180
+
181
+ model, tokenizer = get_splice_model()
182
+ if model is None:
183
+ raise RuntimeError("splice model not loaded")
184
+
185
+ enc = torch.tensor(
186
+ _encode_mutation(ref, alt, sequence, pos)
187
+ ).unsqueeze(0) # (1, 401, 8)
188
+ flags = torch.tensor(
189
+ _compute_splice_flags(sequence)
190
+ ).unsqueeze(0) # (1, 401)
191
+
192
+ with torch.no_grad():
193
+ if tokenizer is not None:
194
+ inputs = tokenizer(
195
+ sequence, return_tensors="pt",
196
+ truncation=True, max_length=512
197
+ )
198
+ out = model(**inputs)
199
+ logits = (
200
+ getattr(out, "logits", None)
201
+ or out.last_hidden_state.mean(-1)
202
+ )
203
+ else:
204
+ # model accepts (1,401,8) — _build_splice_arch handles reshape
205
+ try:
206
+ out = model(enc)
207
+ except TypeError:
208
+ out = model(enc, flags)
209
+
210
+ # out may be a tuple: (logit, imp, r_imp, s_imp)
211
+ logits = out[0] if isinstance(out, (tuple, list)) else out
212
+
213
+ # Extract up to 3 scalar probability values
214
+ arr = torch.sigmoid(logits.squeeze()).cpu().numpy().flatten()
215
+ vals = [float(arr[i]) if i < len(arr) else 0.5 for i in range(3)]
216
+
217
+ # Gradient saliency map
218
+ saliency = None
219
+ try:
220
+ enc2 = torch.tensor(
221
+ _encode_mutation(ref, alt, sequence, pos)
222
+ ).unsqueeze(0).requires_grad_(True)
223
+ out2 = model(enc2)
224
+ logit2 = out2[0] if isinstance(out2, (tuple, list)) else out2
225
+ logit2.squeeze()[0].backward()
226
+ saliency = enc2.grad.abs().squeeze().sum(-1).cpu().numpy()
227
+ except Exception:
228
+ saliency = np.abs(np.random.randn(401)) * vals[0]
229
+
230
+ return SpliceLayerOutput(
231
+ splice_prob = float(np.clip(vals[0], 0, 1)),
232
+ splice_signal_strength = float(np.clip(vals[1], 0, 1)),
233
+ counterfactual_delta = float(vals[2]),
234
+ saliency_map = saliency,
235
+ )
236
+
237
+ except Exception as exc:
238
+ print(f"[PeVe] Splice inference error:\n{traceback.format_exc()}")
239
+ return SpliceLayerOutput(0.0, 0.0, 0.0, None, model_available=False)
240
+
241
+
242
+ def _run_context_model(
243
+ sequence: str, ref: str, alt: str, pos: int
244
+ ) -> ContextLayerOutput:
245
+ try:
246
+ import torch
247
+ from model_loader import get_context_model
248
+
249
+ model, tokenizer = get_context_model()
250
+ if model is None:
251
+ raise RuntimeError("context model not loaded")
252
+
253
+ enc = torch.tensor(
254
+ _encode_mutation(ref, alt, sequence, pos)
255
+ ).unsqueeze(0) # (1, 401, 8)
256
+
257
+ with torch.no_grad():
258
+ if tokenizer is not None:
259
+ inputs = tokenizer(
260
+ sequence, return_tensors="pt",
261
+ truncation=True, max_length=512
262
+ )
263
+ out = model(**inputs)
264
+ logits = (
265
+ getattr(out, "logits", None)
266
+ or out.last_hidden_state.mean(-1)
267
+ )
268
+ else:
269
+ out = model(enc)
270
+ logits = out[0] if isinstance(out, (tuple, list)) else out
271
+
272
+ arr = torch.sigmoid(logits.squeeze()).cpu().numpy().flatten()
273
+ vals = [float(arr[i]) if i < len(arr) else 0.5 for i in range(3)]
274
+
275
+ # Activation peak position via gradient
276
+ peak_pos = 200
277
+ try:
278
+ enc2 = torch.tensor(
279
+ _encode_mutation(ref, alt, sequence, pos)
280
+ ).unsqueeze(0).requires_grad_(True)
281
+ out2 = model(enc2)
282
+ logit2 = out2[0] if isinstance(out2, (tuple, list)) else out2
283
+ logit2.squeeze()[0].backward()
284
+ act = enc2.grad.abs().squeeze().sum(-1).cpu().numpy()
285
+ peak_pos = int(np.argmax(act))
286
+ except Exception:
287
+ pass
288
+
289
+ return ContextLayerOutput(
290
+ context_pathogenic_prob = float(np.clip(vals[0], 0, 1)),
291
+ activation_norm = float(np.clip(vals[1], 0, 1)),
292
+ activation_peak_position= peak_pos,
293
+ importance_score = float(np.clip(vals[2], 0, 1)),
294
+ )
295
+
296
+ except Exception as exc:
297
+ print(f"[PeVe] Context inference error:\n{traceback.format_exc()}")
298
+ return ContextLayerOutput(0.0, 0.0, 200, 0.0, model_available=False)
299
+
300
+
301
+ def _run_protein_model(
302
+ af: float,
303
+ grantham: float,
304
+ charge_change: float,
305
+ hydro_diff: float,
306
+ protein_pos_norm: float,
307
+ vep_impact: str,
308
+ l3_valid: bool,
309
+ ) -> ProteinLayerOutput:
310
+ try:
311
+ import xgboost as xgb
312
+ from model_loader import get_protein_model
313
+
314
+ if not l3_valid:
315
+ return ProteinLayerOutput(0.0, 0.0, {}, l3_substitution_valid=False)
316
+
317
+ model = get_protein_model()
318
+ if model is None:
319
+ raise RuntimeError("protein model not loaded")
320
+
321
+ impact_map = {"HIGH": 3, "MODERATE": 2, "LOW": 1, "MODIFIER": 0}
322
+ imp_num = impact_map.get(str(vep_impact).upper(), 0)
323
+ feat_names = [
324
+ "gnomAD_AF", "Grantham", "Charge_change",
325
+ "Hydrophobicity_diff", "Protein_pos_norm", "VEP_IMPACT",
326
+ ]
327
+ X = np.array(
328
+ [[af, grantham, charge_change, hydro_diff, protein_pos_norm, imp_num]],
329
+ dtype=np.float32,
330
+ )
331
+
332
+ # .predict() — works for both xgb.Booster and _CNNasXGB wrapper
333
+ try:
334
+ dmat = xgb.DMatrix(X, feature_names=feat_names)
335
+ pred = model.predict(dmat)
336
+ except Exception:
337
+ pred = model.predict(X)
338
+
339
+ prob = float(np.asarray(pred).flat[0])
340
+ risk = prob
341
+
342
+ # SHAP
343
+ shap_vals: dict = {}
344
+ try:
345
+ import shap
346
+ explainer = shap.TreeExplainer(model)
347
+ sv = explainer.shap_values(X)
348
+ arr = sv[0] if isinstance(sv, list) else sv
349
+ shap_vals = dict(zip(feat_names, arr[0].tolist()))
350
+ except Exception:
351
+ # Fallback: approximate SHAP from feature weights × values
352
+ w = [0.30, 0.25, 0.20, 0.15, 0.05, 0.05]
353
+ shap_vals = {
354
+ n: float(ww * v)
355
+ for n, ww, v in zip(feat_names, w, X[0].tolist())
356
+ }
357
+
358
+ return ProteinLayerOutput(
359
+ biochemical_risk_score = float(np.clip(risk, 0, 1)),
360
+ feature_pathogenic_prob = float(np.clip(prob, 0, 1)),
361
+ shap_feature_contributions = shap_vals,
362
+ l3_substitution_valid = True,
363
+ )
364
+
365
+ except Exception as exc:
366
+ print(f"[PeVe] Protein inference error:\n{traceback.format_exc()}")
367
+ return ProteinLayerOutput(
368
+ 0.0, 0.0, {},
369
+ l3_substitution_valid=l3_valid,
370
+ model_available=False,
371
+ )
372
+
373
+
374
+ # ══════════════════════════════════════════════════════════════════════════════
375
+ # Main pipeline
376
+ # ══════════════════════════════════════════════════════════════════════════════
377
+
378
+ def run_peve(
379
+ chrom, position, ref, alt,
380
+ transcript_id, ancestry,
381
+ grantham_score, charge_change, hydro_diff, protein_pos_norm,
382
+ ):
383
+ errors: list[str] = []
384
+
385
+ # ── Input sanitisation ────────────────────────────────────────────────────
386
+ chrom = str(chrom).strip().lstrip("chr")
387
+ ref = str(ref).strip().upper()
388
+ alt = str(alt).strip().upper()
389
+ ancestry = str(ancestry).strip().lower() or None
390
+
391
+ try:
392
+ pos = int(position)
393
+ except (ValueError, TypeError):
394
+ return _error_return("Invalid position — must be an integer.")
395
+
396
+ if not ref or not alt:
397
+ return _error_return("Reference and alternate alleles are required.")
398
+
399
+ # ── Step 1: Sequence ──────────────────────────────────────────────────────
400
+ sequence = _fetch_sequence(chrom, pos)
401
+ if not sequence or len(sequence) < 50:
402
+ sequence = "N" * 401
403
+ errors.append(
404
+ "⚠ Sequence extraction failed — placeholder used. "
405
+ "Model outputs unreliable."
406
+ )
407
+
408
+ # ── Step 2: VEP ───────────────────────────────────────────────────────────
409
+ vep = _run_vep(chrom, pos, ref, alt)
410
+
411
+ # ── Step 3: Variant class ─────────────────────────────────────────────────
412
+ vc = classify_variant(ref, alt, vep["consequence"], vep["all_consequences"])
413
+
414
+ # ── Step 4: Allele frequency ──────────────────────────────────────────────
415
+ af_result = fetch_af(chrom, pos, ref, alt, ancestry=ancestry)
416
+
417
+ # ── Step 5: Models ────────────────────────────────────────────────────────
418
+ _ensure_models()
419
+
420
+ splice_out = _run_splice_model(sequence, ref, alt, pos)
421
+ context_out = _run_context_model(sequence, ref, alt, pos)
422
+ protein_out = _run_protein_model(
423
+ af = af_result.global_af if af_result.global_af is not None else 1.0,
424
+ grantham = float(grantham_score),
425
+ charge_change = float(charge_change),
426
+ hydro_diff = float(hydro_diff),
427
+ protein_pos_norm = float(protein_pos_norm),
428
+ vep_impact = vep["impact"],
429
+ l3_valid = vc.l3_substitution_valid,
430
+ )
431
+
432
+ # ── Step 6: Synthesis ─────────────────────────────────────────────────────
433
+ result = synthesize(splice_out, context_out, protein_out, af_result, vc)
434
+
435
+ # ── Step 7: Narrative ─────────────────────────────────────────────────────
436
+ narrative = build_narrative(
437
+ result, splice_out, context_out, protein_out, af_result, vc
438
+ )
439
+
440
+ # ── Step 8: Visualisations ────────────────────────────────────────────────
441
+ fig_summary = render_summary_card(result, chrom, pos, ref, alt)
442
+ fig_saliency = render_saliency_heatmap(splice_out, ref, alt)
443
+ fig_peak = render_activation_peak(context_out, ref, alt)
444
+ fig_shap = render_shap_bar(protein_out)
445
+ fig_gauges = render_band_gauges(result, splice_out, context_out, protein_out)
446
+ html_conflict = render_conflict_table(result)
447
+
448
+ # ── Step 9: JSON export ───────────────────────────────────────────────────
449
+ export = _build_export(
450
+ result, splice_out, context_out, protein_out,
451
+ af_result, vc, vep, chrom, pos, ref, alt,
452
+ )
453
+ export_json = json.dumps(export, indent=2, default=str)
454
+
455
+ # ── Step 10: Text summaries ───────────────────────────────────────────────
456
+ flag_text = "\n".join(vc.flags) if vc.flags else "None"
457
+ if errors:
458
+ flag_text = "\n".join(errors) + "\n" + flag_text
459
+
460
+ rna_txt = (
461
+ f"splice_prob: {splice_out.splice_prob:.4f}\n"
462
+ f"splice_signal_strength: {splice_out.splice_signal_strength:.4f}\n"
463
+ f"counterfactual_delta: {splice_out.counterfactual_delta:.4f}\n"
464
+ f"Band: {result.activation_levels.splice_band}\n"
465
+ f"RNA Active: {result.activation_levels.rna_active}\n"
466
+ f"RNA Dominant: {result.activation_levels.rna_dominant}"
467
+ )
468
+ ctx_txt = (
469
+ f"activation_norm: {context_out.activation_norm:.4f}\n"
470
+ f"activation_peak_pos: {context_out.activation_peak_position}\n"
471
+ f"importance_score: {context_out.importance_score:.4f}\n"
472
+ f"Band: {result.activation_levels.context_band}\n"
473
+ f"Context Active: {result.activation_levels.context_active}"
474
+ )
475
+ prot_txt = (
476
+ f"biochemical_risk_score: {protein_out.biochemical_risk_score:.4f}\n"
477
+ f"feature_pathogenic_prob: {protein_out.feature_pathogenic_prob:.4f}\n"
478
+ f"AF global: {format_af_display(af_result)}\n"
479
+ f"AF state: {af_result.state}\n"
480
+ f"Protein Active: {result.activation_levels.protein_active}\n"
481
+ f"L3 Valid: {protein_out.l3_substitution_valid}"
482
+ )
483
+ ann_txt = (
484
+ f"VEP: {vep['consequence']} | IMPACT: {vep['impact']} | "
485
+ f"Gene: {vep['gene']} | Tx: {vep['transcript']}\n"
486
+ f"Variant class: {vc.variant_class}\n"
487
+ f"Transcript conflict: {vc.transcript_conflict}"
488
+ )
489
+
490
+ status_msg = (
491
+ f"✓ chr{chrom}:{pos} {ref}>{alt} → "
492
+ f"{result.dominant_mechanism} | {result.final_classification}"
493
+ )
494
+
495
+ # 14 outputs — must match Gradio wiring exactly
496
+ return (
497
+ status_msg,
498
+ fig_summary, fig_gauges, fig_saliency,
499
+ rna_txt, fig_peak, ctx_txt,
500
+ fig_shap, prot_txt,
501
+ html_conflict, flag_text, narrative, export_json, ann_txt,
502
+ )
503
+
504
+
505
+ # ══════════════════════════════════════════════════════════════════════════════
506
+ # Export builder
507
+ # ══════════════════════════════════════════════════════════════════════════════
508
+
509
+ def _build_export(
510
+ result, splice, context, protein,
511
+ af_result, vc, vep,
512
+ chrom, pos, ref, alt,
513
+ ) -> dict:
514
+ return {
515
+ "peve_version": PEVE_VERSION,
516
+ "threshold_version": THRESHOLD_VERSION,
517
+ "input": {
518
+ "chromosome": chrom,
519
+ "position": pos,
520
+ "ref": ref,
521
+ "alt": alt,
522
+ },
523
+ "variant_class": vc.variant_class,
524
+ "vep_annotation": vep,
525
+ "dominant_mechanism": result.dominant_mechanism,
526
+ "final_classification": result.final_classification,
527
+ "supporting_mechanisms":result.supporting_mechanisms,
528
+ "activation_levels": {
529
+ "splice_band": result.activation_levels.splice_band,
530
+ "rna_active": result.activation_levels.rna_active,
531
+ "rna_dominant": result.activation_levels.rna_dominant,
532
+ "context_band": result.activation_levels.context_band,
533
+ "context_active": result.activation_levels.context_active,
534
+ "protein_active": result.activation_levels.protein_active,
535
+ },
536
+ "layer_outputs": {
537
+ "RNA": {
538
+ "splice_prob": splice.splice_prob,
539
+ "splice_signal_strength": splice.splice_signal_strength,
540
+ "counterfactual_delta": splice.counterfactual_delta,
541
+ "model_available": splice.model_available,
542
+ },
543
+ "context": {
544
+ "context_pathogenic_prob": context.context_pathogenic_prob,
545
+ "activation_norm": context.activation_norm,
546
+ "activation_peak_position": context.activation_peak_position,
547
+ "importance_score": context.importance_score,
548
+ "model_available": context.model_available,
549
+ },
550
+ "protein": {
551
+ "biochemical_risk_score": protein.biochemical_risk_score,
552
+ "feature_pathogenic_prob": protein.feature_pathogenic_prob,
553
+ "shap_feature_contributions": protein.shap_feature_contributions,
554
+ "l3_substitution_valid": protein.l3_substitution_valid,
555
+ "model_available": protein.model_available,
556
+ },
557
+ },
558
+ "af": {
559
+ "state": af_result.state,
560
+ "global_af": af_result.global_af,
561
+ "is_rare": af_result.is_rare,
562
+ "founder_variant_flag":af_result.founder_variant_flag,
563
+ },
564
+ "conflict_report": {
565
+ "major_conflicts": result.conflict_report.major_conflicts,
566
+ "minor_conflicts": result.conflict_report.minor_conflicts,
567
+ "requires_manual_review":result.conflict_report.requires_manual_review,
568
+ "conflict_score_major": result.conflict_report.conflict_score_major,
569
+ "conflict_score_minor": result.conflict_report.conflict_score_minor,
570
+ },
571
+ "reasoning_steps": result.reasoning_steps,
572
+ "transcript_ambiguity":result.transcript_ambiguity,
573
+ "af_uncertainty": result.af_uncertainty,
574
+ "prefilter_flags": vc.flags,
575
+ }
576
+
577
+
578
+ # ══════════════════════════════════════════════════════════════════════════════
579
+ # Error return (14 outputs, matching wiring)
580
+ # ══════════════════════════════════════════════════════════════════════════════
581
+
582
+ def _error_return(msg: str):
583
+ import matplotlib.pyplot as plt
584
+
585
+ fig = plt.figure(figsize=(4, 2))
586
+ plt.text(0.5, 0.5, msg, ha="center", va="center", wrap=True)
587
+ plt.axis("off")
588
+ plt.tight_layout()
589
+
590
+ return (
591
+ f"❌ {msg}", # status_out
592
+ fig, fig, fig, # fig_summary, fig_gauges, fig_saliency
593
+ msg, # rna_txt
594
+ fig, # fig_peak
595
+ msg, # ctx_txt
596
+ fig, # fig_shap
597
+ msg, # prot_txt
598
+ f"<div style='color:red;padding:8px'>{msg}</div>", # conflict_html
599
+ msg, # flag_txt
600
+ msg, # narrative_out
601
+ "{}", # json_out
602
+ msg, # ann_txt
603
+ )
604
+
605
+
606
+ # ══════════════════════════════════════════════════════════════════════════════
607
+ # Gradio UI
608
+ # ══════════════════════════════════════════════════════════════════════════════
609
+
610
+ _EXAMPLES = [
611
+ ["17", "43092176", "G", "T", "", "nfe", 215.0, 0.0, 0.5, 0.45],
612
+ ["17", "7675088", "C", "T", "", "", 101.0, 1.0, 0.3, 0.30],
613
+ ["1", "69270", "A", "G", "", "", 0.0, 0.0, 0.0, 0.50],
614
+ ]
615
+
616
+ _HEADER = f"""
617
+ # 🧬 PeVe — Deterministic Variant Reasoning Engine
618
+ **v{PEVE_VERSION}** · Threshold set {THRESHOLD_VERSION}
619
+
620
+ Three-layer biological mechanism framework for genomic variant interpretation.
621
+ No probability averaging · No weighted ensemble · No confidence scores.
622
+
623
+ | Layer | Model | Biological question |
624
+ |---|---|---|
625
+ | 1 · RNA | mutation-predictor-splice | Is splicing disrupted? |
626
+ | 2 · Sequence | mutation-predictor-v4 | Does local context show disruptive signal? |
627
+ | 3 · Protein | mutation-pathogenicity-predictor | Does protein impact + rarity support pathogenicity? |
628
+
629
+ > **Research tool only. Not validated for clinical use.**
630
+ """
631
+
632
+ _CSS = """
633
+ .section-header {
634
+ font-weight: bold;
635
+ border-left: 4px solid #4575b4;
636
+ padding-left: 10px;
637
+ margin-top: 14px;
638
+ font-size: 15px;
639
+ }
640
+ """
641
+
642
+
643
+ def build_ui() -> gr.Blocks:
644
+ with gr.Blocks(
645
+ title="PeVe — Deterministic Variant Reasoning",
646
+ theme=gr.themes.Base(primary_hue="blue"),
647
+ css=_CSS,
648
+ ) as demo:
649
+
650
+ gr.Markdown(_HEADER)
651
+
652
+ # ── Inputs ────────────────────────────────────────────────────────────
653
+ with gr.Row():
654
+ with gr.Column(scale=1):
655
+ gr.Markdown("### Variant Coordinates (GRCh38)")
656
+ chrom_in = gr.Textbox(label="Chromosome", value="17", placeholder="17")
657
+ pos_in = gr.Textbox(label="Position", value="43092176")
658
+ ref_in = gr.Textbox(label="Reference allele",value="G")
659
+ alt_in = gr.Textbox(label="Alternate allele",value="T")
660
+ tx_in = gr.Textbox(label="Transcript ID (optional)", placeholder="ENST…")
661
+ anc_in = gr.Textbox(
662
+ label="Ancestry (optional)",
663
+ placeholder="nfe / eas / asj / afr / amr",
664
+ )
665
+
666
+ with gr.Column(scale=1):
667
+ gr.Markdown("### Biochemical Features — Layer 3 Input")
668
+ gr.Markdown(
669
+ "_Required for missense variants. "
670
+ "Leave defaults for splice / non-coding._"
671
+ )
672
+ gran_in = gr.Slider(
673
+ 0, 215, value=100, step=1,
674
+ label="Grantham Score (0 = identical → 215 = extreme)",
675
+ )
676
+ chg_in = gr.Slider(
677
+ -2, 2, value=0, step=1,
678
+ label="Charge Change (−2 to +2)",
679
+ )
680
+ hyd_in = gr.Slider(
681
+ -5.0, 5.0, value=0.0, step=0.1,
682
+ label="Hydrophobicity Difference",
683
+ )
684
+ pp_in = gr.Slider(
685
+ 0.0, 1.0, value=0.5, step=0.01,
686
+ label="Protein Position Normalised (0 = N-term → 1 = C-term)",
687
+ )
688
+
689
+ run_btn = gr.Button("▶ Analyse Variant", variant="primary", size="lg")
690
+ status_out = gr.Textbox(label="Status", interactive=False, lines=1)
691
+
692
+ gr.Markdown("---")
693
+
694
+ # ── Section 1: Summary ────────────────────────────────────────────────
695
+ gr.HTML('<div class="section-header">SECTION 1 — Summary &amp; Activation Bands</div>')
696
+ with gr.Row():
697
+ fig_summary = gr.Plot(label="Summary Card")
698
+ fig_gauges = gr.Plot(label="Mechanism Activation Bands")
699
+
700
+ # ── Section 2: RNA ────────────────────────────────────────────────────
701
+ gr.HTML('<div class="section-header">SECTION 2 — RNA Mechanism (Layer 1 · Splice Model)</div>')
702
+ fig_saliency = gr.Plot(label="Saliency Heatmap (mutation centre = red dashed)")
703
+ rna_txt = gr.Textbox(label="RNA Layer Metrics", interactive=False, lines=6)
704
+
705
+ # ── Section 3: Sequence Context ───────────────────────────────────────
706
+ gr.HTML('<div class="section-header">SECTION 3 — Sequence Context (Layer 2 · CNN)</div>')
707
+ fig_peak = gr.Plot(label="Activation Peak vs Mutation Position")
708
+ ctx_txt = gr.Textbox(label="Context Layer Metrics", interactive=False, lines=5)
709
+
710
+ # ── Section 4: Protein ────────────────────────────────────────────────
711
+ gr.HTML('<div class="section-header">SECTION 4 — Protein &amp; Population (Layer 3)</div>')
712
+ fig_shap = gr.Plot(label="SHAP Feature Contributions")
713
+ prot_txt = gr.Textbox(label="Protein / AF Layer Metrics", interactive=False, lines=6)
714
+
715
+ # ── Section 5: Conflicts ──────────────────────────────────────────────
716
+ gr.HTML('<div class="section-header">SECTION 5 — Conflict &amp; Boundary Flags</div>')
717
+ conflict_html = gr.HTML()
718
+ with gr.Accordion("Annotation & Pre-filter Details", open=False):
719
+ ann_txt = gr.Textbox(label="VEP Annotation", interactive=False, lines=3)
720
+ flag_txt = gr.Textbox(label="Pre-filter Flags",interactive=False, lines=4)
721
+
722
+ # ── Section 6: Narrative ──────────────────────────────────────────────
723
+ gr.HTML('<div class="section-header">SECTION 6 — Structured Reasoning Narrative</div>')
724
+ narrative_out = gr.Textbox(
725
+ label="Deterministic reasoning (template-based, NOT LLM-generated)",
726
+ interactive=False,
727
+ lines=22,
728
+ )
729
+
730
+ # ── Export ────────────────────────────────────────────────────────────
731
+ with gr.Accordion("Export Full Result as JSON", open=False):
732
+ json_out = gr.Code(label="JSON", language="json", lines=28)
733
+
734
+ # ── Examples ──────────────────────────────────────────────────────────
735
+ gr.Markdown("### Example Variants")
736
+ gr.Examples(
737
+ examples=_EXAMPLES,
738
+ inputs=[
739
+ chrom_in, pos_in, ref_in, alt_in,
740
+ tx_in, anc_in,
741
+ gran_in, chg_in, hyd_in, pp_in,
742
+ ],
743
+ )
744
+
745
+ # ── Wiring ────────────────────────────────────────────────────────────
746
+ _inputs = [
747
+ chrom_in, pos_in, ref_in, alt_in,
748
+ tx_in, anc_in,
749
+ gran_in, chg_in, hyd_in, pp_in,
750
+ ]
751
+ _outputs = [
752
+ status_out,
753
+ fig_summary, fig_gauges, fig_saliency,
754
+ rna_txt, fig_peak, ctx_txt,
755
+ fig_shap, prot_txt,
756
+ conflict_html, flag_txt, narrative_out, json_out, ann_txt,
757
+ ]
758
+
759
+ run_btn.click(fn=run_peve, inputs=_inputs, outputs=_outputs)
760
+
761
+ gr.Markdown(
762
+ f"\n---\n_PeVe v{PEVE_VERSION} · "
763
+ "Deterministic biological mechanism routing · "
764
+ "Research use only_"
765
+ )
766
+
767
+ return demo
768
+
769
+
770
+ # ══════════════════════════════════════════════════════════════════════════════
771
+ # Entry point
772
+ # ═══════════════════════════════���══════════════════════════════════════════════
773
+
774
+ if __name__ == "__main__":
775
+ build_ui().launch(
776
+ server_name="0.0.0.0",
777
+ server_port=7860,
778
+ show_error=True,
779
+ )