F4RUKYLDRM commited on
Commit
121eaa8
Β·
1 Parent(s): 72c0d0b

Fix dataset loading and add intro cards

Browse files
Files changed (5) hide show
  1. app.py +304 -86
  2. biolmnet/data.py +8 -2
  3. biolmnet/ui/components.py +110 -0
  4. biolmnet/ui/styles.py +196 -1
  5. tests/test_core.py +62 -0
app.py CHANGED
@@ -106,6 +106,17 @@ def _resolve_embedding_file(option: str, source_mode: str, example_dataset: str)
106
  return "embedding_original_large_3.parquet"
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
109
  # ── rail / chrome state machine (presentation only) ─────────────────────
110
 
111
 
@@ -422,22 +433,43 @@ def prepare_workspace(
422
  f"{len(label_names)} classes Β· {len(gene_branch.pathways) + len(dna_branch.pathways):,} "
423
  f"branch-specific pathways Β· GenePT: {html.escape(embedding_file)}{warning_html}"
424
  )
425
- note = ui.esc(
426
- f"{len(resolved_rows)} of {len(resolved_rows)} inputs resolved"
427
- + (f" Β· {len(warnings)} warning(s)" if warnings else "")
428
- + f" Β· architecture built {datetime.now().strftime('%H:%M:%S')}"
 
 
 
 
429
  )
430
  run_meta = {
431
  "source_name": source_name,
432
  "embedding_file": embedding_file,
433
  "unmatched_pathway_symbols": unmatched_symbols,
434
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  progress(1.0, desc="Ready to train")
436
- return workspace, summary, architecture, enrichments.head(100), resolved_files, run_meta, note
437
  except Exception as exc:
438
- return None, _status(str(exc), error=True), empty, empty, empty, {}, ui.esc(
439
  "Choose a source, then build the biological architecture."
440
- )
 
 
441
 
442
 
443
  def _reveal_symbols(run_meta: dict):
@@ -611,7 +643,8 @@ def train_workspace(
611
 
612
  minutes, seconds = divmod(int(elapsed_seconds), 60)
613
  metrics_html = ui.simple_status_html(
614
- "<strong>Training complete.</strong> Best validation checkpoint restored; the "
 
615
  "downloadable artifact includes architecture, preprocessing, weights and metrics."
616
  )
617
  loss_figure = _loss_plot(bundle.history)
@@ -731,19 +764,26 @@ def _resolve_predict_bundle(model_state, artifact_path, use_upload: bool):
731
  return model_state
732
 
733
 
734
- def refresh_alignment(model_state, artifact_path, use_upload, gene_path, dna_path):
 
 
 
 
 
 
 
735
  """Presentation-only pre-flight check: mirrors the two structural checks
736
  ``validate_prediction_frames`` performs (row-count match, required
737
  columns present) purely to render the alignment table live. The actual
738
  gating decision on submit still goes through the real, untouched
739
  ``predict()`` call."""
740
- align = {"ok": None, "required": None, "matched": None, "source_label": "Session model"}
741
  try:
742
  bundle = _resolve_predict_bundle(model_state, artifact_path, use_upload)
743
  except Exception as exc:
744
  rows = [[html.escape("Artifact"), "β€”", "β€”", f'<span class="error">{html.escape(str(exc))}</span>']]
745
  table = ui.table_html(["Check", "Artifact", "Uploaded", "Result"], rows, aligns=["left", "left", "left", "right"])
746
- return table, gr.update(visible=False), gr.update(interactive=False), align
747
 
748
  if use_upload:
749
  align["source_label"] = "Uploaded artifact" if artifact_path else "Upload artifact"
@@ -753,14 +793,21 @@ def refresh_alignment(model_state, artifact_path, use_upload, gene_path, dna_pat
753
  [["Trained model available", "β€”", "β€”", '<span class="error">Fail</span>']],
754
  aligns=["left", "left", "left", "right"],
755
  )
756
- return table, gr.update(visible=False), gr.update(interactive=False), align
757
 
758
  align["required"] = len(bundle.gene_features) + len(bundle.dna_features)
759
 
760
  rows = []
761
  ok = True
762
  gene_frame = dna_frame = None
763
- if gene_path and dna_path:
 
 
 
 
 
 
 
764
  try:
765
  gene_frame = read_csv(gene_path)
766
  dna_frame = read_csv(dna_path)
@@ -800,7 +847,7 @@ def refresh_alignment(model_state, artifact_path, use_upload, gene_path, dna_pat
800
  return table, gr.update(visible=strip_visible, value=(
801
  ui.strip_text_html("Blocking Β· features missing", f"{missing_total:,} required column(s) are missing from the uploaded files.")
802
  if strip_visible else ""
803
- )), gr.update(interactive=bool(ok)), align
804
 
805
 
806
  def _pass_fail(ok: bool) -> str:
@@ -816,14 +863,28 @@ def _list_missing(align: dict):
816
  return gr.update(visible=True, value=f'<div class="num" style="word-break:break-word">{html.escape(shown)}{more}</div>')
817
 
818
 
819
- def run_prediction(bundle: ModelBundle | None, uploaded_artifact: str | None, use_upload: bool, gene_file: str | None, dna_file: str | None, predict_meta: dict):
 
 
 
 
 
 
 
 
 
820
  predict_meta = dict(predict_meta or {})
821
  try:
822
  active_bundle = load_bundle(uploaded_artifact) if (use_upload and uploaded_artifact) else bundle
823
  if active_bundle is None:
824
  raise ValueError("Train a model in Phase 2 or upload a BioLM-NET model artifact.")
825
- gene_frame = _read_required_upload(gene_file, "a prediction gene-expression CSV")
826
- dna_frame = _read_required_upload(dna_file, "a prediction DNA-methylation CSV")
 
 
 
 
 
827
  output = predict(gene_frame, dna_frame, active_bundle)
828
  destination = Path(tempfile.mkdtemp(prefix="biolmnet-prediction-")) / "biolm-net-predictions.csv"
829
  output.to_csv(destination, index=False)
@@ -923,6 +984,8 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
923
  predict_meta_state = gr.State({})
924
  active_page_state = gr.State("data")
925
  session_id_state = gr.State("")
 
 
926
 
927
  with gr.Row(elem_classes=["app-shell"]):
928
  with gr.Column(elem_classes=["rail-col"]):
@@ -943,7 +1006,9 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
943
  gr.HTML(ui.footnote_html("Research use only", "ZeroGPU on demand Β· py 3.12"))
944
 
945
  with gr.Column(elem_classes=["workspace-col"]):
946
- topbar_html_component = gr.HTML(ui.topbar_html(1, STAGE_TOTAL, STAGE_LABELS[0], "β€”β€”β€”β€”β€”β€”", "ZeroGPU idle"))
 
 
947
 
948
  # ── Data & Priors ────────────────────────────────────────
949
  with gr.Column(visible=True) as data_page:
@@ -1066,16 +1131,7 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1066
  )
1067
 
1068
  gr.HTML('<div class="mono" style="margin:24px 0 9px">Graph preview</div>')
1069
- graph_preview = gr.HTML(
1070
- ui.blueprint_div(
1071
- '<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px 10px">'
1072
- + ui.mini_stat_html("β€”", "Mask density")
1073
- + ui.mini_stat_html("β€”", "Gene nodes")
1074
- + ui.mini_stat_html("β€”", "Pathway units")
1075
- + ui.mini_stat_html("β€”", "GenePT dim")
1076
- + "</div>"
1077
- )
1078
- )
1079
 
1080
  with gr.Row(visible=False):
1081
  architecture_table = gr.Dataframe(interactive=False, wrap=False, value=pd.DataFrame(), headers=[""], column_count=(1, "dynamic"))
@@ -1230,6 +1286,10 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1230
  with gr.Row(elem_classes=["stage-grid"], equal_height=False):
1231
  with gr.Column(scale=6, min_width=0):
1232
  gr.HTML('<div class="mono sect">Inputs</div>')
 
 
 
 
1233
  with gr.Row(elem_classes=["srow-row", "first"], visible=True) as session_model_row:
1234
  with gr.Column(min_width=100):
1235
  gr.HTML(ui.esc("Trained artifact"))
@@ -1240,12 +1300,17 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1240
  gr.HTML(ui.esc("Trained artifact"))
1241
  with gr.Column():
1242
  prediction_artifact = gr.File(label="", show_label=False, file_types=[".zip"], type="filepath", elem_classes=["file-slot"])
1243
- with gr.Row(elem_classes=["srow-row"]):
 
 
 
 
 
1244
  with gr.Column(min_width=100):
1245
  gr.HTML(ui.esc("Gene expression"))
1246
  with gr.Column():
1247
  prediction_gene = gr.File(label="", show_label=False, file_types=[".csv"], type="filepath", elem_classes=["file-slot"])
1248
- with gr.Row(elem_classes=["srow-row", "last"]):
1249
  with gr.Column(min_width=100):
1250
  gr.HTML(ui.esc("DNA methylation"))
1251
  with gr.Column():
@@ -1307,6 +1372,40 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1307
  "drawing biological or clinical conclusions.</div>"
1308
  )
1309
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1310
  # ═════════════════════════════════════════════════════════════════
1311
  # Wiring
1312
  # ═════════════════════════════════════════════════════════════════
@@ -1326,18 +1425,76 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1326
  _nav(key), inputs=chrome_inputs_tail, outputs=[active_page_state, *page_columns, *chrome_outputs]
1327
  )
1328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1329
  demo.load(
1330
- lambda: secrets.token_hex(3), outputs=[session_id_state]
1331
  ).then(
1332
  lambda sid, *rest: _refresh_chrome("data", *rest, sid), inputs=[session_id_state, *chrome_inputs_tail[:-1]], outputs=chrome_outputs
1333
  )
1334
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1335
  source_mode.change(
1336
  _source_visibility, inputs=[source_mode], outputs=[example_group, github_group, upload_group]
1337
  )
1338
 
1339
- def _busy(label):
1340
- return gr.update(value=label, interactive=False)
 
 
 
 
 
 
 
 
1341
 
1342
  prepare_inputs = [
1343
  source_mode, example_dataset, github_folder, uploaded_gene, uploaded_dna, uploaded_labels,
@@ -1348,35 +1505,93 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1348
  def _after_prepare(workspace, run_meta, session_id):
1349
  chrome = _refresh_chrome("data", workspace, None, run_meta, {}, {}, session_id)
1350
  return (
1351
- gr.update(value="Build biological architecture", interactive=True),
1352
- gr.update(value="Re-validate inputs", interactive=True),
1353
  *chrome,
1354
  )
1355
 
1356
- for trigger in (prepare_button, revalidate_button):
1357
- trigger.click(
1358
- lambda: (_busy("Building…"), _busy("Building…")), outputs=[prepare_button, revalidate_button],
1359
- show_progress="hidden",
1360
- ).then(
1361
- prepare_workspace, inputs=prepare_inputs,
1362
- outputs=[
1363
- workspace_state, preparation_status, architecture_table, enrichment_table,
1364
- resolved_files_table, run_meta_state, data_actionbar_note,
1365
- ],
1366
- show_progress="minimal",
1367
- ).then(
1368
- lambda note: f'<div class="actbar-note">{note}</div>', inputs=[data_actionbar_note], outputs=[data_actionbar_note],
1369
- show_progress="hidden",
1370
- ).then(
1371
- _after_prepare, inputs=[workspace_state, run_meta_state, session_id_state],
1372
- outputs=[prepare_button, revalidate_button, *chrome_outputs],
1373
- show_progress="hidden",
1374
  )
1375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1376
  show_symbols_btn.click(_reveal_symbols, inputs=[run_meta_state], outputs=[unmatched_reveal])
1377
  pdi_override_btn.click(lambda: gr.update(visible=True), outputs=[uploaded_pdi])
1378
  ppi_override_btn.click(lambda: gr.update(visible=True), outputs=[uploaded_ppi])
1379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1380
  # `train_workspace`'s positional signature intentionally mirrors
1381
  # `estimate_training_duration` exactly for `@spaces.GPU` β€” session
1382
  # metadata (dataset name, elapsed time, …) is threaded through
@@ -1389,7 +1604,7 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1389
 
1390
  def _after_train(bundle, run_meta, session_id):
1391
  chrome = _refresh_chrome("train", bundle is not None and True, bundle, run_meta, {}, {}, session_id)
1392
- return gr.update(value="Train BioLM-NET on ZeroGPU", interactive=True), *chrome
1393
 
1394
  validation_predictions_state = gr.State(pd.DataFrame())
1395
  importance_state = gr.State(pd.DataFrame())
@@ -1398,7 +1613,7 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1398
  run_meta_update_state = gr.State({})
1399
 
1400
  train_button.click(
1401
- lambda: _busy("Training on ZeroGPU…"), outputs=[train_button],
1402
  show_progress="hidden",
1403
  ).then(
1404
  train_workspace, inputs=train_inputs,
@@ -1406,7 +1621,7 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1406
  model_state, training_status, loss_plot, validation_predictions_state, artifact_path_state,
1407
  importance_state, architecture_audit_state, run_meta_update_state, confusion_html_state,
1408
  ],
1409
- show_progress="minimal",
1410
  ).then(
1411
  lambda old, new: {**(old or {}), **(new or {})},
1412
  inputs=[run_meta_state, run_meta_update_state], outputs=[run_meta_state],
@@ -1415,6 +1630,10 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1415
  _after_train, inputs=[model_state, run_meta_state, session_id_state],
1416
  outputs=[train_button, *chrome_outputs],
1417
  show_progress="hidden",
 
 
 
 
1418
  ).then(
1419
  lambda bundle, artifact_path, run_meta: _export_panel(bundle, artifact_path, run_meta),
1420
  inputs=[model_state, artifact_path_state, run_meta_state],
@@ -1441,30 +1660,22 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1441
  ).then(
1442
  lambda bundle: gr.update(visible=bundle is not None), inputs=[model_state], outputs=[results_tables_bottom],
1443
  show_progress="hidden",
1444
- ).then(
1445
- lambda bundle, run_meta: gr.update(value=_status(
1446
- f"Training complete Β· validation accuracy {bundle.metrics['accuracy']:.3f} Β· elapsed {run_meta.get('elapsed','β€”')}"
1447
- )) if bundle else gr.update(),
1448
- inputs=[model_state, run_meta_state], outputs=[training_status],
1449
- show_progress="hidden",
1450
  )
1451
-
1452
- def _predict_source_toggle(mode):
1453
- return gr.update(visible=mode == "Session model"), gr.update(visible=mode == "Upload artifact")
1454
-
1455
- predict_source_mode.change(
1456
- _predict_source_toggle, inputs=[predict_source_mode], outputs=[session_model_row, artifact_upload_row]
1457
- )
1458
-
1459
- def _use_upload(mode):
1460
- return mode == "Upload artifact"
1461
-
1462
- align_inputs = [model_state, prediction_artifact, predict_source_mode, prediction_gene, prediction_dna]
1463
-
1464
- def _align_wrapper(bundle, artifact_path, mode, gene_path, dna_path):
1465
- return refresh_alignment(bundle, artifact_path, mode == "Upload artifact", gene_path, dna_path)
1466
-
1467
- for control in (predict_source_mode, prediction_artifact, prediction_gene, prediction_dna, model_state):
1468
  control.change(
1469
  _align_wrapper, inputs=align_inputs,
1470
  outputs=[alignment_table, alignment_strip, predict_button, align_state],
@@ -1472,22 +1683,29 @@ with gr.Blocks(title="BioLM-NET Workbench", fill_width=True) as demo:
1472
 
1473
  list_missing_btn.click(_list_missing, inputs=[align_state], outputs=[missing_reveal])
1474
 
1475
- predict_inputs = [model_state, prediction_artifact, predict_source_mode, prediction_gene, prediction_dna, predict_meta_state]
 
 
 
1476
 
1477
- def _predict_wrapper(bundle, artifact_path, mode, gene_path, dna_path, predict_meta):
1478
- return run_prediction(bundle, artifact_path, mode == "Upload artifact", gene_path, dna_path, predict_meta)
 
 
 
1479
 
1480
  predict_button.click(
1481
- lambda: _busy("Running inference…"), outputs=[predict_button],
1482
  show_progress="hidden",
1483
  ).then(
1484
  _predict_wrapper, inputs=predict_inputs,
1485
  outputs=[model_state, prediction_status, prediction_table, distribution_panel, prediction_download, predict_meta_state],
1486
- show_progress="minimal",
1487
- ).then(
1488
- lambda: gr.update(value="Run inference", interactive=True), outputs=[predict_button],
1489
  show_progress="hidden",
1490
  ).then(
 
 
 
 
1491
  _align_wrapper, inputs=align_inputs, outputs=[alignment_table, alignment_strip, predict_button, align_state],
1492
  show_progress="hidden",
1493
  ).then(
 
106
  return "embedding_original_large_3.parquet"
107
 
108
 
109
+ def _graph_preview_html(mask_density: str, gene_nodes: str, pathway_units: str, genept_dim: str) -> str:
110
+ return ui.blueprint_div(
111
+ '<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px 10px">'
112
+ + ui.mini_stat_html(mask_density, "Mask density")
113
+ + ui.mini_stat_html(gene_nodes, "Gene nodes")
114
+ + ui.mini_stat_html(pathway_units, "Pathway units")
115
+ + ui.mini_stat_html(genept_dim, "GenePT dim")
116
+ + "</div>"
117
+ )
118
+
119
+
120
  # ── rail / chrome state machine (presentation only) ─────────────────────
121
 
122
 
 
433
  f"{len(label_names)} classes Β· {len(gene_branch.pathways) + len(dna_branch.pathways):,} "
434
  f"branch-specific pathways Β· GenePT: {html.escape(embedding_file)}{warning_html}"
435
  )
436
+ note = (
437
+ '<div class="actbar-note">'
438
+ + ui.esc(
439
+ f"{len(resolved_rows)} of {len(resolved_rows)} inputs resolved"
440
+ + (f" Β· {len(warnings)} warning(s)" if warnings else "")
441
+ + f" Β· architecture built {datetime.now().strftime('%H:%M:%S')}"
442
+ )
443
+ + "</div>"
444
  )
445
  run_meta = {
446
  "source_name": source_name,
447
  "embedding_file": embedding_file,
448
  "unmatched_pathway_symbols": unmatched_symbols,
449
  }
450
+ mean_density = float(
451
+ np.mean(
452
+ [
453
+ gene_branch.biological_mask.astype(bool).mean(),
454
+ dna_branch.biological_mask.astype(bool).mean(),
455
+ ]
456
+ )
457
+ )
458
+ genept_dim = gene_branch.embeddings.shape[1] if gene_branch.embeddings is not None else 0
459
+ graph_preview_html = _graph_preview_html(
460
+ f"{mean_density * 100:.1f}%",
461
+ f"{len(gene_branch.hidden_genes) + len(dna_branch.hidden_genes):,}",
462
+ f"{len(gene_branch.pathways) + len(dna_branch.pathways):,}",
463
+ f"{genept_dim:,}",
464
+ )
465
  progress(1.0, desc="Ready to train")
466
+ return workspace, summary, architecture, enrichments.head(100), resolved_files, run_meta, note, graph_preview_html
467
  except Exception as exc:
468
+ fallback_note = '<div class="actbar-note">' + ui.esc(
469
  "Choose a source, then build the biological architecture."
470
+ ) + "</div>"
471
+ empty_graph_preview = _graph_preview_html("β€”", "β€”", "β€”", "β€”")
472
+ return None, _status(str(exc), error=True), empty, empty, empty, {}, fallback_note, empty_graph_preview
473
 
474
 
475
  def _reveal_symbols(run_meta: dict):
 
643
 
644
  minutes, seconds = divmod(int(elapsed_seconds), 60)
645
  metrics_html = ui.simple_status_html(
646
+ f"<strong>Training complete.</strong> Validation accuracy {metrics['accuracy']:.3f} Β· "
647
+ f"elapsed {minutes:02d}:{seconds:02d}. Best validation checkpoint restored; the "
648
  "downloadable artifact includes architecture, preprocessing, weights and metrics."
649
  )
650
  loss_figure = _loss_plot(bundle.history)
 
764
  return model_state
765
 
766
 
767
+ def _workspace_prediction_frames(workspace: PreparedWorkspace) -> tuple[pd.DataFrame, pd.DataFrame]:
768
+ return (
769
+ pd.DataFrame(workspace.gene_expression, columns=workspace.gene_branch.input_genes),
770
+ pd.DataFrame(workspace.dna_methylation, columns=workspace.dna_branch.input_genes),
771
+ )
772
+
773
+
774
+ def refresh_alignment(model_state, workspace, artifact_path, use_upload, use_prepared, gene_path, dna_path):
775
  """Presentation-only pre-flight check: mirrors the two structural checks
776
  ``validate_prediction_frames`` performs (row-count match, required
777
  columns present) purely to render the alignment table live. The actual
778
  gating decision on submit still goes through the real, untouched
779
  ``predict()`` call."""
780
+ align = {"ok": None, "required": None, "matched": None, "source_label": "Prepared dataset" if use_prepared else "Session model"}
781
  try:
782
  bundle = _resolve_predict_bundle(model_state, artifact_path, use_upload)
783
  except Exception as exc:
784
  rows = [[html.escape("Artifact"), "β€”", "β€”", f'<span class="error">{html.escape(str(exc))}</span>']]
785
  table = ui.table_html(["Check", "Artifact", "Uploaded", "Result"], rows, aligns=["left", "left", "left", "right"])
786
+ return table, gr.update(visible=False), gr.update(value="Run inference", interactive=False, elem_classes=["actbar-primary-btn"]), align
787
 
788
  if use_upload:
789
  align["source_label"] = "Uploaded artifact" if artifact_path else "Upload artifact"
 
793
  [["Trained model available", "β€”", "β€”", '<span class="error">Fail</span>']],
794
  aligns=["left", "left", "left", "right"],
795
  )
796
+ return table, gr.update(visible=False), gr.update(value="Run inference", interactive=False, elem_classes=["actbar-primary-btn"]), align
797
 
798
  align["required"] = len(bundle.gene_features) + len(bundle.dna_features)
799
 
800
  rows = []
801
  ok = True
802
  gene_frame = dna_frame = None
803
+ if use_prepared:
804
+ if workspace is None:
805
+ rows.append(["Prepared dataset available", "β€”", "β€”", '<span class="error">Fail</span>'])
806
+ ok = False
807
+ else:
808
+ gene_frame, dna_frame = _workspace_prediction_frames(workspace)
809
+ rows.append(["Prepared dataset available", "β€”", f"{len(gene_frame):,} samples", _pass_fail(True)])
810
+ elif gene_path and dna_path:
811
  try:
812
  gene_frame = read_csv(gene_path)
813
  dna_frame = read_csv(dna_path)
 
847
  return table, gr.update(visible=strip_visible, value=(
848
  ui.strip_text_html("Blocking Β· features missing", f"{missing_total:,} required column(s) are missing from the uploaded files.")
849
  if strip_visible else ""
850
+ )), gr.update(value="Run inference", interactive=bool(ok), elem_classes=["actbar-primary-btn"]), align
851
 
852
 
853
  def _pass_fail(ok: bool) -> str:
 
863
  return gr.update(visible=True, value=f'<div class="num" style="word-break:break-word">{html.escape(shown)}{more}</div>')
864
 
865
 
866
+ def run_prediction(
867
+ bundle: ModelBundle | None,
868
+ workspace: PreparedWorkspace | None,
869
+ uploaded_artifact: str | None,
870
+ use_upload: bool,
871
+ use_prepared: bool,
872
+ gene_file: str | None,
873
+ dna_file: str | None,
874
+ predict_meta: dict,
875
+ ):
876
  predict_meta = dict(predict_meta or {})
877
  try:
878
  active_bundle = load_bundle(uploaded_artifact) if (use_upload and uploaded_artifact) else bundle
879
  if active_bundle is None:
880
  raise ValueError("Train a model in Phase 2 or upload a BioLM-NET model artifact.")
881
+ if use_prepared:
882
+ if workspace is None:
883
+ raise ValueError("Prepare data and priors before scoring the prepared dataset.")
884
+ gene_frame, dna_frame = _workspace_prediction_frames(workspace)
885
+ else:
886
+ gene_frame = _read_required_upload(gene_file, "a prediction gene-expression CSV")
887
+ dna_frame = _read_required_upload(dna_file, "a prediction DNA-methylation CSV")
888
  output = predict(gene_frame, dna_frame, active_bundle)
889
  destination = Path(tempfile.mkdtemp(prefix="biolmnet-prediction-")) / "biolm-net-predictions.csv"
890
  output.to_csv(destination, index=False)
 
984
  predict_meta_state = gr.State({})
985
  active_page_state = gr.State("data")
986
  session_id_state = gr.State("")
987
+ intro_step_state = gr.State(0)
988
+ intro_dismissed_state = gr.BrowserState(False, storage_key="biolmnet_intro_dismissed")
989
 
990
  with gr.Row(elem_classes=["app-shell"]):
991
  with gr.Column(elem_classes=["rail-col"]):
 
1006
  gr.HTML(ui.footnote_html("Research use only", "ZeroGPU on demand Β· py 3.12"))
1007
 
1008
  with gr.Column(elem_classes=["workspace-col"]):
1009
+ with gr.Row(elem_classes=["topbar-host"]):
1010
+ topbar_html_component = gr.HTML(ui.topbar_html(1, STAGE_TOTAL, STAGE_LABELS[0], "β€”β€”β€”β€”β€”β€”", "ZeroGPU idle"))
1011
+ intro_open_button = gr.Button("Introduction", size="sm", variant="secondary", elem_classes=["btn-ghost", "intro-open-btn"])
1012
 
1013
  # ── Data & Priors ────────────────────────────────────────
1014
  with gr.Column(visible=True) as data_page:
 
1131
  )
1132
 
1133
  gr.HTML('<div class="mono" style="margin:24px 0 9px">Graph preview</div>')
1134
+ graph_preview = gr.HTML(_graph_preview_html("β€”", "β€”", "β€”", "β€”"))
 
 
 
 
 
 
 
 
 
1135
 
1136
  with gr.Row(visible=False):
1137
  architecture_table = gr.Dataframe(interactive=False, wrap=False, value=pd.DataFrame(), headers=[""], column_count=(1, "dynamic"))
 
1286
  with gr.Row(elem_classes=["stage-grid"], equal_height=False):
1287
  with gr.Column(scale=6, min_width=0):
1288
  gr.HTML('<div class="mono sect">Inputs</div>')
1289
+ prediction_input_mode = gr.Radio(
1290
+ ["Prepared dataset", "Upload files"], value="Prepared dataset", show_label=False,
1291
+ container=False, elem_classes=["seg-radio"],
1292
+ )
1293
  with gr.Row(elem_classes=["srow-row", "first"], visible=True) as session_model_row:
1294
  with gr.Column(min_width=100):
1295
  gr.HTML(ui.esc("Trained artifact"))
 
1300
  gr.HTML(ui.esc("Trained artifact"))
1301
  with gr.Column():
1302
  prediction_artifact = gr.File(label="", show_label=False, file_types=[".zip"], type="filepath", elem_classes=["file-slot"])
1303
+ with gr.Row(elem_classes=["srow-row"]) as prepared_dataset_row:
1304
+ with gr.Column(min_width=100):
1305
+ gr.HTML(ui.esc("Prediction cohort"))
1306
+ with gr.Column():
1307
+ gr.HTML('<div class="num" style="opacity:.65">Use the dataset prepared in Data &amp; Priors.</div>')
1308
+ with gr.Row(elem_classes=["srow-row"], visible=False) as prediction_gene_row:
1309
  with gr.Column(min_width=100):
1310
  gr.HTML(ui.esc("Gene expression"))
1311
  with gr.Column():
1312
  prediction_gene = gr.File(label="", show_label=False, file_types=[".csv"], type="filepath", elem_classes=["file-slot"])
1313
+ with gr.Row(elem_classes=["srow-row", "last"], visible=False) as prediction_dna_row:
1314
  with gr.Column(min_width=100):
1315
  gr.HTML(ui.esc("DNA methylation"))
1316
  with gr.Column():
 
1372
  "drawing biological or clinical conclusions.</div>"
1373
  )
1374
 
1375
+ with gr.Group(elem_id="intro", visible=True) as intro_group:
1376
+ intro_cards = []
1377
+ intro_next_buttons = []
1378
+ intro_back_buttons = []
1379
+ intro_skip_buttons = []
1380
+ intro_start_buttons = []
1381
+ with gr.Column(elem_classes=["intro-card"]):
1382
+ for index, (_, _, _, _, note) in enumerate(ui.INTRO_CARDS):
1383
+ with gr.Column(visible=index == 0, elem_classes=["intro-card-page"]) as intro_card:
1384
+ gr.HTML(ui.intro_card_html(index))
1385
+ with gr.Row(elem_classes=["intro-card-foot"]):
1386
+ if index == 0:
1387
+ intro_dont_show = gr.Checkbox(value=False, label="Don't show again", container=False)
1388
+ else:
1389
+ gr.HTML(f'<span class="mono" style="font-size:9.5px">{ui.esc(note)}</span>')
1390
+ with gr.Row(elem_classes=["intro-card-actions"]):
1391
+ gr.HTML(ui.intro_dots_html(index))
1392
+ if index == 0:
1393
+ skip_btn = gr.Button("Skip", size="sm", variant="secondary", elem_classes=["btn-ghost"])
1394
+ next_btn = gr.Button("Next", size="sm", variant="primary")
1395
+ intro_skip_buttons.append(skip_btn)
1396
+ intro_next_buttons.append(next_btn)
1397
+ elif index < len(ui.INTRO_CARDS) - 1:
1398
+ back_btn = gr.Button("Back", size="sm", variant="secondary", elem_classes=["btn-ghost"])
1399
+ next_btn = gr.Button("Next", size="sm", variant="primary")
1400
+ intro_back_buttons.append(back_btn)
1401
+ intro_next_buttons.append(next_btn)
1402
+ else:
1403
+ back_btn = gr.Button("Back", size="sm", variant="secondary", elem_classes=["btn-ghost"])
1404
+ start_btn = gr.Button("Start with the BRCA example", size="sm", variant="primary")
1405
+ intro_back_buttons.append(back_btn)
1406
+ intro_start_buttons.append(start_btn)
1407
+ intro_cards.append(intro_card)
1408
+
1409
  # ═════════════════════════════════════════════════════════════════
1410
  # Wiring
1411
  # ═════════════════════════════════════════════════════════════════
 
1425
  _nav(key), inputs=chrome_inputs_tail, outputs=[active_page_state, *page_columns, *chrome_outputs]
1426
  )
1427
 
1428
+ def _intro_updates(index: int, visible: bool = True):
1429
+ index = max(0, min(len(ui.INTRO_CARDS) - 1, int(index)))
1430
+ cards = [gr.update(visible=i == index) for i in range(len(ui.INTRO_CARDS))]
1431
+ return index, gr.update(visible=visible), *cards
1432
+
1433
+ def _intro_load(dismissed):
1434
+ return secrets.token_hex(3), *_intro_updates(0, visible=not bool(dismissed))
1435
+
1436
+ def _intro_close(dont_show):
1437
+ return 0, gr.update(visible=False), bool(dont_show)
1438
+
1439
+ def _intro_start_brca(dont_show):
1440
+ return (
1441
+ 0,
1442
+ gr.update(visible=False),
1443
+ bool(dont_show),
1444
+ "BioLM-NET examples",
1445
+ "BRCA",
1446
+ *_source_visibility("BioLM-NET examples"),
1447
+ )
1448
+
1449
+ intro_outputs = [intro_step_state, intro_group, *intro_cards]
1450
+
1451
  demo.load(
1452
+ _intro_load, inputs=[intro_dismissed_state], outputs=[session_id_state, *intro_outputs]
1453
  ).then(
1454
  lambda sid, *rest: _refresh_chrome("data", *rest, sid), inputs=[session_id_state, *chrome_inputs_tail[:-1]], outputs=chrome_outputs
1455
  )
1456
 
1457
+ intro_open_button.click(
1458
+ lambda: _intro_updates(0, True), outputs=intro_outputs, show_progress="hidden"
1459
+ )
1460
+ for index, button in enumerate(intro_next_buttons):
1461
+ button.click(
1462
+ lambda i=index: _intro_updates(i + 1, True), outputs=intro_outputs, show_progress="hidden"
1463
+ )
1464
+ for index, button in enumerate(intro_back_buttons, start=1):
1465
+ button.click(
1466
+ lambda i=index: _intro_updates(i - 1, True), outputs=intro_outputs, show_progress="hidden"
1467
+ )
1468
+ for button in intro_skip_buttons:
1469
+ button.click(
1470
+ _intro_close, inputs=[intro_dont_show],
1471
+ outputs=[intro_step_state, intro_group, intro_dismissed_state],
1472
+ show_progress="hidden",
1473
+ )
1474
+ for button in intro_start_buttons:
1475
+ button.click(
1476
+ _intro_start_brca, inputs=[intro_dont_show],
1477
+ outputs=[
1478
+ intro_step_state, intro_group, intro_dismissed_state,
1479
+ source_mode, example_dataset, example_group, github_group, upload_group,
1480
+ ],
1481
+ show_progress="hidden",
1482
+ )
1483
+
1484
  source_mode.change(
1485
  _source_visibility, inputs=[source_mode], outputs=[example_group, github_group, upload_group]
1486
  )
1487
 
1488
+ # The one deliberate loading indicator (a thin animated bar on the busy
1489
+ # button itself β€” see `.is-busy` in styles.py) is toggled only through
1490
+ # these two helpers, explicitly, on exactly the button that was clicked.
1491
+ # It is never left to Gradio's own per-component pending state, which is
1492
+ # what caused several of these to appear at once for one click.
1493
+ def _busy(label, base_classes):
1494
+ return gr.update(value=label, interactive=False, elem_classes=[*base_classes, "is-busy"])
1495
+
1496
+ def _idle(label, base_classes, interactive=True):
1497
+ return gr.update(value=label, interactive=interactive, elem_classes=list(base_classes))
1498
 
1499
  prepare_inputs = [
1500
  source_mode, example_dataset, github_folder, uploaded_gene, uploaded_dna, uploaded_labels,
 
1505
  def _after_prepare(workspace, run_meta, session_id):
1506
  chrome = _refresh_chrome("data", workspace, None, run_meta, {}, {}, session_id)
1507
  return (
1508
+ _idle("Build biological architecture", ["actbar-primary-btn"]),
1509
+ _idle("Re-validate inputs", ["actbar-secondary-btn"]),
1510
  *chrome,
1511
  )
1512
 
1513
+ # Only the button actually clicked gets the sweep; its sibling just goes
1514
+ # inert (disabled, unchanged label) β€” two buttons both sweeping for one
1515
+ # action would itself be the "more than one indicator" problem.
1516
+ def _busy_pair(which: str):
1517
+ if which == "primary":
1518
+ return (
1519
+ _busy("Building…", ["actbar-primary-btn"]),
1520
+ gr.update(interactive=False),
1521
+ )
1522
+ return (
1523
+ gr.update(interactive=False),
1524
+ _busy("Building…", ["actbar-secondary-btn"]),
 
 
 
 
 
 
1525
  )
1526
 
1527
+ prepare_button.click(
1528
+ lambda: _busy_pair("primary"), outputs=[prepare_button, revalidate_button],
1529
+ show_progress="hidden",
1530
+ ).then(
1531
+ prepare_workspace, inputs=prepare_inputs,
1532
+ outputs=[
1533
+ workspace_state, preparation_status, architecture_table, enrichment_table,
1534
+ resolved_files_table, run_meta_state, data_actionbar_note, graph_preview,
1535
+ ],
1536
+ show_progress="hidden",
1537
+ ).then(
1538
+ _after_prepare, inputs=[workspace_state, run_meta_state, session_id_state],
1539
+ outputs=[prepare_button, revalidate_button, *chrome_outputs],
1540
+ show_progress="hidden",
1541
+ )
1542
+
1543
+ revalidate_button.click(
1544
+ lambda: _busy_pair("secondary"), outputs=[prepare_button, revalidate_button],
1545
+ show_progress="hidden",
1546
+ ).then(
1547
+ prepare_workspace, inputs=prepare_inputs,
1548
+ outputs=[
1549
+ workspace_state, preparation_status, architecture_table, enrichment_table,
1550
+ resolved_files_table, run_meta_state, data_actionbar_note, graph_preview,
1551
+ ],
1552
+ show_progress="hidden",
1553
+ ).then(
1554
+ _after_prepare, inputs=[workspace_state, run_meta_state, session_id_state],
1555
+ outputs=[prepare_button, revalidate_button, *chrome_outputs],
1556
+ show_progress="hidden",
1557
+ )
1558
+
1559
  show_symbols_btn.click(_reveal_symbols, inputs=[run_meta_state], outputs=[unmatched_reveal])
1560
  pdi_override_btn.click(lambda: gr.update(visible=True), outputs=[uploaded_pdi])
1561
  ppi_override_btn.click(lambda: gr.update(visible=True), outputs=[uploaded_ppi])
1562
 
1563
+ def _predict_source_toggle(mode):
1564
+ return gr.update(visible=mode == "Session model"), gr.update(visible=mode == "Upload artifact")
1565
+
1566
+ predict_source_mode.change(
1567
+ _predict_source_toggle, inputs=[predict_source_mode], outputs=[session_model_row, artifact_upload_row]
1568
+ )
1569
+
1570
+ def _predict_input_toggle(mode):
1571
+ use_uploads = mode == "Upload files"
1572
+ return (
1573
+ gr.update(visible=not use_uploads),
1574
+ gr.update(visible=use_uploads),
1575
+ gr.update(visible=use_uploads),
1576
+ )
1577
+
1578
+ prediction_input_mode.change(
1579
+ _predict_input_toggle,
1580
+ inputs=[prediction_input_mode],
1581
+ outputs=[prepared_dataset_row, prediction_gene_row, prediction_dna_row],
1582
+ )
1583
+
1584
+ align_inputs = [
1585
+ model_state, workspace_state, prediction_artifact, predict_source_mode,
1586
+ prediction_input_mode, prediction_gene, prediction_dna,
1587
+ ]
1588
+
1589
+ def _align_wrapper(bundle, workspace, artifact_path, model_mode, input_mode, gene_path, dna_path):
1590
+ return refresh_alignment(
1591
+ bundle, workspace, artifact_path, model_mode == "Upload artifact",
1592
+ input_mode == "Prepared dataset", gene_path, dna_path,
1593
+ )
1594
+
1595
  # `train_workspace`'s positional signature intentionally mirrors
1596
  # `estimate_training_duration` exactly for `@spaces.GPU` β€” session
1597
  # metadata (dataset name, elapsed time, …) is threaded through
 
1604
 
1605
  def _after_train(bundle, run_meta, session_id):
1606
  chrome = _refresh_chrome("train", bundle is not None and True, bundle, run_meta, {}, {}, session_id)
1607
+ return _idle("Train BioLM-NET on ZeroGPU", ["actbar-primary-btn"]), *chrome
1608
 
1609
  validation_predictions_state = gr.State(pd.DataFrame())
1610
  importance_state = gr.State(pd.DataFrame())
 
1613
  run_meta_update_state = gr.State({})
1614
 
1615
  train_button.click(
1616
+ lambda: _busy("Training on ZeroGPU…", ["actbar-primary-btn"]), outputs=[train_button],
1617
  show_progress="hidden",
1618
  ).then(
1619
  train_workspace, inputs=train_inputs,
 
1621
  model_state, training_status, loss_plot, validation_predictions_state, artifact_path_state,
1622
  importance_state, architecture_audit_state, run_meta_update_state, confusion_html_state,
1623
  ],
1624
+ show_progress="hidden",
1625
  ).then(
1626
  lambda old, new: {**(old or {}), **(new or {})},
1627
  inputs=[run_meta_state, run_meta_update_state], outputs=[run_meta_state],
 
1630
  _after_train, inputs=[model_state, run_meta_state, session_id_state],
1631
  outputs=[train_button, *chrome_outputs],
1632
  show_progress="hidden",
1633
+ ).then(
1634
+ _align_wrapper, inputs=align_inputs,
1635
+ outputs=[alignment_table, alignment_strip, predict_button, align_state],
1636
+ show_progress="hidden",
1637
  ).then(
1638
  lambda bundle, artifact_path, run_meta: _export_panel(bundle, artifact_path, run_meta),
1639
  inputs=[model_state, artifact_path_state, run_meta_state],
 
1660
  ).then(
1661
  lambda bundle: gr.update(visible=bundle is not None), inputs=[model_state], outputs=[results_tables_bottom],
1662
  show_progress="hidden",
 
 
 
 
 
 
1663
  )
1664
+ # training_status is NOT re-set here: train_workspace's own return
1665
+ # already carries the final "Training complete Β· accuracy Β· elapsed"
1666
+ # message (it has the elapsed time on hand already), so writing it
1667
+ # again from run_meta_state afterwards was a second, redundant paint
1668
+ # of the same component for one click.
1669
+
1670
+ # Deliberately NOT including `model_state` here: it only ever changes as
1671
+ # part of the train/predict chains below, and both of those already call
1672
+ # `_align_wrapper` explicitly as their own finalize step. Adding it here
1673
+ # too would fire alignment twice per click (train_workspace/_predict_
1674
+ # wrapper set model_state -> this listener fires -> the chain's own
1675
+ # explicit call also fires) β€” the exact "loading bar twice" symptom.
1676
+ # `workspace_state` stays: nothing else re-checks alignment when the
1677
+ # user rebuilds the architecture while already on the Predict page.
1678
+ for control in (predict_source_mode, prediction_input_mode, prediction_artifact, prediction_gene, prediction_dna, workspace_state):
 
 
1679
  control.change(
1680
  _align_wrapper, inputs=align_inputs,
1681
  outputs=[alignment_table, alignment_strip, predict_button, align_state],
 
1683
 
1684
  list_missing_btn.click(_list_missing, inputs=[align_state], outputs=[missing_reveal])
1685
 
1686
+ predict_inputs = [
1687
+ model_state, workspace_state, prediction_artifact, predict_source_mode,
1688
+ prediction_input_mode, prediction_gene, prediction_dna, predict_meta_state,
1689
+ ]
1690
 
1691
+ def _predict_wrapper(bundle, workspace, artifact_path, model_mode, input_mode, gene_path, dna_path, predict_meta):
1692
+ return run_prediction(
1693
+ bundle, workspace, artifact_path, model_mode == "Upload artifact",
1694
+ input_mode == "Prepared dataset", gene_path, dna_path, predict_meta,
1695
+ )
1696
 
1697
  predict_button.click(
1698
+ lambda: _busy("Running inference…", ["actbar-primary-btn"]), outputs=[predict_button],
1699
  show_progress="hidden",
1700
  ).then(
1701
  _predict_wrapper, inputs=predict_inputs,
1702
  outputs=[model_state, prediction_status, prediction_table, distribution_panel, prediction_download, predict_meta_state],
 
 
 
1703
  show_progress="hidden",
1704
  ).then(
1705
+ # This one call both resets the button's label back to "Run
1706
+ # inference" (out of its transient "Running inference…" busy state)
1707
+ # and sets the correct interactive flag β€” no separate blind
1708
+ # re-enable step first, which used to write predict_button twice.
1709
  _align_wrapper, inputs=align_inputs, outputs=[alignment_table, alignment_strip, predict_button, align_state],
1710
  show_progress="hidden",
1711
  ).then(
biolmnet/data.py CHANGED
@@ -57,6 +57,12 @@ def _normalise_columns(frame: pd.DataFrame) -> pd.DataFrame:
57
  return result
58
 
59
 
 
 
 
 
 
 
60
  def read_csv(source: str | Path | BinaryIO) -> pd.DataFrame:
61
  if hasattr(source, "read"):
62
  return _normalise_columns(pd.read_csv(source))
@@ -415,8 +421,7 @@ def load_genept_embeddings(filename: str) -> pd.DataFrame:
415
  repo_type="model",
416
  )
417
  frame = pd.read_parquet(path)
418
- frame.index = frame.index.astype(str).str.strip()
419
- return frame
420
 
421
 
422
  def attach_embeddings_and_pathways(
@@ -426,6 +431,7 @@ def attach_embeddings_and_pathways(
426
  *,
427
  precomputed_significant: bool,
428
  ) -> pd.DataFrame:
 
429
  embedding_index = set(embedding_frame.index.astype(str))
430
  keep = np.array(
431
  [gene in embedding_index for gene in branch.hidden_genes], dtype=bool
 
57
  return result
58
 
59
 
60
+ def _normalise_embedding_index(frame: pd.DataFrame) -> pd.DataFrame:
61
+ result = frame.copy()
62
+ result.index = result.index.astype(str).str.strip()
63
+ return result[~result.index.duplicated(keep="first")]
64
+
65
+
66
  def read_csv(source: str | Path | BinaryIO) -> pd.DataFrame:
67
  if hasattr(source, "read"):
68
  return _normalise_columns(pd.read_csv(source))
 
421
  repo_type="model",
422
  )
423
  frame = pd.read_parquet(path)
424
+ return _normalise_embedding_index(frame)
 
425
 
426
 
427
  def attach_embeddings_and_pathways(
 
431
  *,
432
  precomputed_significant: bool,
433
  ) -> pd.DataFrame:
434
+ embedding_frame = _normalise_embedding_index(embedding_frame)
435
  embedding_index = set(embedding_frame.index.astype(str))
436
  keep = np.array(
437
  [gene in embedding_index for gene in branch.hidden_genes], dtype=bool
biolmnet/ui/components.py CHANGED
@@ -228,6 +228,116 @@ def empty_note_html(message: str) -> str:
228
  return f'<div class="empty-note">{esc(message)}</div>'
229
 
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  # ── misc ─────────────────────────────────────────────────────────────────
232
 
233
  def human_bytes(n: float) -> str:
 
228
  return f'<div class="empty-note">{esc(message)}</div>'
229
 
230
 
231
+ # ── introduction cards ────────────────────────────────────────────────────
232
+
233
+ INTRO_CARDS = [
234
+ (
235
+ "Introduction Β· 01 / 06",
236
+ "A network wired along known biology",
237
+ (
238
+ "BioLM-NET classifies tumour samples from <b>paired gene expression and DNA methylation</b>. "
239
+ "Instead of connecting every gene to every neuron, it only makes the connections biology supports β€” "
240
+ "KEGG pathways, transcription-factor targets, protein interactions β€” so trained weights can be read "
241
+ "back as pathways rather than as a black box."
242
+ ),
243
+ "This workbench runs the model end to end in five stages. On the bundled BRCA example it takes a few minutes.",
244
+ "",
245
+ ),
246
+ (
247
+ "Introduction Β· 02 / 06",
248
+ "Five stages, in order",
249
+ (
250
+ "The rail on the left is the spine of the app. Each stage consumes the previous one's output, so a "
251
+ "stage stays <b>locked</b> until its prerequisite exists β€” you cannot export a model you have not trained."
252
+ ),
253
+ (
254
+ '<div class="intro-stage-list">'
255
+ "<div><em>01</em><span><b>Data &amp; Priors</b> β€” assemble the masked graph</span></div>"
256
+ "<div><em>02</em><span><b>Train Model</b> β€” fit it, watch the epochs</span></div>"
257
+ "<div><em>03</em><span><b>Export Artifacts</b> β€” save a reusable bundle</span></div>"
258
+ "<div><em>04</em><span><b>Predict</b> β€” score a new cohort</span></div>"
259
+ "<div><em>05</em><span><b>Results</b> β€” metrics and pathway attention</span></div>"
260
+ "</div>"
261
+ ),
262
+ "The plate under the rail always says what state the model is in",
263
+ ),
264
+ (
265
+ "Introduction Β· 03 / 06 Β· Stage 01",
266
+ "Data & Priors",
267
+ (
268
+ "Point the workbench at paired omics β€” a bundled example, a GitHub folder, or your own upload β€” with "
269
+ "<b>samples in rows and HGNC symbols in columns</b>. The two matrices are aligned on their shared "
270
+ "samples, and every file is reported with the shape it was read at rather than failing silently."
271
+ ),
272
+ (
273
+ "The priors on the right are the biology that becomes wiring: pathway annotations, the enrichment "
274
+ "cutoff, the GenePT context and the interaction sources. Defaults follow the paper."
275
+ ),
276
+ "Rebuilding clears any trained model",
277
+ ),
278
+ (
279
+ "Introduction Β· 04 / 06 Β· Stage 02",
280
+ "Train Model",
281
+ (
282
+ "Hyperparameters sit on the left as a spec sheet. The run panel on the right reports <b>epoch, train "
283
+ "and validation loss, accuracy and ETA</b> as the fit progresses."
284
+ ),
285
+ (
286
+ "Controls lock for the duration of a run and the primary button reads <b>Training on ZeroGPU</b> "
287
+ "until it finishes; Cancel stays available. The GPU is allocated on demand, so the first epoch may "
288
+ "wait for its reservation."
289
+ ),
290
+ "Paper defaults are one click away",
291
+ ),
292
+ (
293
+ "Introduction Β· 05 / 06 Β· Stages 03–04",
294
+ "Export, then predict",
295
+ (
296
+ "Export writes one bundle carrying the weights, the fitted preprocessing, the mask and the config β€” "
297
+ "enough to score new samples later without rebuilding the graph. Its manifest lists what each entry "
298
+ "reproduces, with a checksum."
299
+ ),
300
+ (
301
+ "Predict scores a new cohort with either the session model or an uploaded bundle. Features are "
302
+ "<b>aligned to the artifact first</b>; if a required column is missing, inference is blocked and the "
303
+ "check that failed is named."
304
+ ),
305
+ "Bundles are temp files β€” download before the Space restarts",
306
+ ),
307
+ (
308
+ "Introduction Β· 06 / 06 Β· Stage 05",
309
+ "Reading the results",
310
+ (
311
+ "Results collects everything a run produced: validation metrics, the training curve, the confusion "
312
+ "matrix, an audit of how sparse each layer actually is, and the pathway attention weights β€” which is "
313
+ "the part the architecture exists to give you."
314
+ ),
315
+ (
316
+ "Treat it as model output, not as biology. <b>Check cohort composition, preprocessing and class "
317
+ "balance before drawing conclusions</b>, and remember that attention weights rank pathways within "
318
+ "this fit rather than proving mechanism."
319
+ ),
320
+ "Research use only Β· not for clinical decisions",
321
+ ),
322
+ ]
323
+
324
+
325
+ def intro_card_html(index: int) -> str:
326
+ step, title, body_1, body_2, _ = INTRO_CARDS[index]
327
+ return (
328
+ f'<div class="intro-step">{esc(step)}</div>'
329
+ f"<h4>{esc(title)}</h4>"
330
+ f"<p>{body_1}</p>"
331
+ f"<p>{body_2}</p>"
332
+ )
333
+
334
+
335
+ def intro_dots_html(index: int, total: int = 6) -> str:
336
+ return '<div class="intro-dots">' + "".join(
337
+ f'<i class="{"on" if i == index else ""}"></i>' for i in range(total)
338
+ ) + "</div>"
339
+
340
+
341
  # ── misc ─────────────────────────────────────────────────────────────────
342
 
343
  def human_bytes(n: float) -> str:
biolmnet/ui/styles.py CHANGED
@@ -274,7 +274,25 @@ body, .gradio-container {
274
  .kv-plain.last { border-bottom: none; }
275
 
276
  /* ── topbar / page head / action bar ─────────────────────────────────── */
277
- .topbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 13px 30px; border-bottom: 1px solid var(--color-divider); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  .pghd { padding: 26px 30px 0; display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
279
  .pghd h2 { font-size: 34px; line-height: 1; color: var(--color-text); }
280
  .pghd .desc { margin-top: 5px; max-width: 640px; color: var(--color-muted); font-size: 13.5px; line-height: 1.5; }
@@ -510,6 +528,45 @@ body, .gradio-container {
510
  .gradio-container input[type="range"] { accent-color: var(--color-accent); height: 3px; }
511
  .gradio-container :focus-visible { outline: 2px solid var(--color-accent) !important; outline-offset: 2px; }
512
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  /* dataframes (.tbl-like) */
514
  .gradio-container table { color: var(--color-text) !important; font-size: 12.5px !important; }
515
  .gradio-container thead th {
@@ -526,6 +583,144 @@ body, .gradio-container {
526
  /* accordion */
527
  .gradio-container .label-wrap { color: var(--color-text) !important; }
528
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  /* ── responsive ───────────────────────────────────────────────────────── */
530
  @media (max-width: 1000px) {
531
  .app-shell { display: block !important; }
 
274
  .kv-plain.last { border-bottom: none; }
275
 
276
  /* ── topbar / page head / action bar ─────────────────────────────────── */
277
+ .topbar-host {
278
+ position: relative;
279
+ display: block !important;
280
+ padding: 0 !important;
281
+ gap: 0 !important;
282
+ }
283
+ .topbar-host > .block:first-child { width: 100% !important; }
284
+ .topbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 13px 132px 13px 30px; border-bottom: 1px solid var(--color-divider); }
285
+ .intro-open-btn {
286
+ position: absolute !important;
287
+ right: 30px;
288
+ top: 8px;
289
+ z-index: 5;
290
+ }
291
+ .intro-open-btn button {
292
+ font-size: 11.5px !important;
293
+ min-height: 28px !important;
294
+ padding: 0 8px !important;
295
+ }
296
  .pghd { padding: 26px 30px 0; display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
297
  .pghd h2 { font-size: 34px; line-height: 1; color: var(--color-text); }
298
  .pghd .desc { margin-top: 5px; max-width: 640px; color: var(--color-muted); font-size: 13.5px; line-height: 1.5; }
 
528
  .gradio-container input[type="range"] { accent-color: var(--color-accent); height: 3px; }
529
  .gradio-container :focus-visible { outline: 2px solid var(--color-accent) !important; outline-offset: 2px; }
530
 
531
+ /* Gradio pulses a 2px accent border + animation around every component
532
+ whose value is currently pending ("generating"), independent of
533
+ show_progress. One action often updates several visible components at
534
+ once (a table, a note, a stat panel, …), so this reads as multiple
535
+ flashing "loading bars" appearing together β€” we already surface busy
536
+ state through the triggering button's own label (see app.py's `_busy`
537
+ helper), so drop Gradio's border/animation entirely rather than have it
538
+ multiply per output. */
539
+ .gradio-container .generating {
540
+ animation: none !important;
541
+ border-color: transparent !important;
542
+ border-width: 0 !important;
543
+ background: none !important;
544
+ }
545
+
546
+ /* The one deliberate loading indicator: a thin animated bar along the
547
+ bottom edge of whichever action button is currently busy. Toggled
548
+ explicitly by app.py's `_busy`/`_idle` helpers via the `is-busy` class β€”
549
+ never by Gradio's own per-component pending state β€” so exactly one of
550
+ these can ever be on screen at a time. */
551
+ /* elem_classes lands directly on the <button> tag for gr.Button (no
552
+ wrapper div) β€” cover both that and a wrapper div defensively. */
553
+ .is-busy, .is-busy button {
554
+ position: relative;
555
+ overflow: hidden;
556
+ }
557
+ .is-busy::after, .is-busy button::after {
558
+ content: "";
559
+ position: absolute;
560
+ left: 0; bottom: 0; height: 2px; width: 35%;
561
+ background: currentColor;
562
+ opacity: .7;
563
+ animation: workbench-busy-sweep 1.1s ease-in-out infinite;
564
+ }
565
+ @keyframes workbench-busy-sweep {
566
+ 0% { transform: translateX(-100%); }
567
+ 100% { transform: translateX(285%); }
568
+ }
569
+
570
  /* dataframes (.tbl-like) */
571
  .gradio-container table { color: var(--color-text) !important; font-size: 12.5px !important; }
572
  .gradio-container thead th {
 
583
  /* accordion */
584
  .gradio-container .label-wrap { color: var(--color-text) !important; }
585
 
586
+ /* ── first-open introduction ─────────────────────────────────────────── */
587
+ #intro {
588
+ position: fixed !important;
589
+ inset: 0 !important;
590
+ z-index: 60 !important;
591
+ min-height: 100vh !important;
592
+ display: grid !important;
593
+ place-items: center !important;
594
+ padding: 24px !important;
595
+ background: rgba(29, 31, 32, .6) !important;
596
+ border: 0 !important;
597
+ }
598
+ #intro[style*="display: none"] {
599
+ display: none !important;
600
+ }
601
+ #intro > .form,
602
+ #intro .form {
603
+ display: contents !important;
604
+ }
605
+ .intro-card {
606
+ position: relative;
607
+ width: min(556px, calc(100vw - 48px)) !important;
608
+ background: var(--color-bg) !important;
609
+ border: 1px solid rgba(29,31,32,.18) !important;
610
+ padding: 26px 28px 20px !important;
611
+ box-shadow: 0 18px 44px rgba(0,0,0,.18) !important;
612
+ }
613
+ .intro-card::before,
614
+ .intro-card::after {
615
+ content: "";
616
+ position: absolute;
617
+ top: -6px;
618
+ bottom: -6px;
619
+ width: 11px;
620
+ pointer-events: none;
621
+ border-color: color-mix(in srgb, var(--color-text) 55%, transparent);
622
+ }
623
+ .intro-card::before {
624
+ left: -6px;
625
+ border-left: 1px solid;
626
+ border-top: 1px solid;
627
+ border-bottom: 1px solid;
628
+ }
629
+ .intro-card::after {
630
+ right: -6px;
631
+ border-right: 1px solid;
632
+ border-top: 1px solid;
633
+ border-bottom: 1px solid;
634
+ }
635
+ .intro-card-page {
636
+ gap: 0 !important;
637
+ }
638
+ .intro-step {
639
+ font: 500 10px/1.3 var(--font-mono);
640
+ letter-spacing: .1em;
641
+ text-transform: uppercase;
642
+ color: var(--color-accent-700);
643
+ }
644
+ .intro-card h4 {
645
+ margin: 10px 0 10px;
646
+ font: 600 27px/1 var(--font-heading);
647
+ color: var(--color-text);
648
+ }
649
+ .intro-card p {
650
+ margin: 0 0 10px;
651
+ font-size: 13.5px;
652
+ line-height: 1.55;
653
+ color: color-mix(in srgb, var(--color-text) 74%, transparent) !important;
654
+ }
655
+ .intro-stage-list {
656
+ display: flex;
657
+ flex-direction: column;
658
+ gap: 8px;
659
+ margin: 8px 0 6px;
660
+ }
661
+ .intro-stage-list div {
662
+ display: grid;
663
+ grid-template-columns: 28px minmax(0, 1fr);
664
+ gap: 8px;
665
+ align-items: baseline;
666
+ font-size: 13px;
667
+ }
668
+ .intro-stage-list em {
669
+ font: 500 10px/1 var(--font-mono);
670
+ color: var(--color-accent-700);
671
+ font-style: normal;
672
+ }
673
+ .intro-card-foot {
674
+ align-items: center !important;
675
+ justify-content: space-between !important;
676
+ gap: 12px 16px !important;
677
+ margin-top: 16px !important;
678
+ padding-top: 14px !important;
679
+ border-top: 1px solid var(--color-divider) !important;
680
+ flex-wrap: wrap !important;
681
+ }
682
+ .intro-card-foot > *:first-child {
683
+ flex: 1 1 auto !important;
684
+ min-width: 0 !important;
685
+ max-width: 100% !important;
686
+ overflow-wrap: anywhere;
687
+ }
688
+ .intro-card-actions {
689
+ align-items: center !important;
690
+ justify-content: flex-end !important;
691
+ flex: 1 0 220px !important;
692
+ gap: 12px !important;
693
+ flex-wrap: nowrap !important;
694
+ width: auto !important;
695
+ margin-left: auto !important;
696
+ }
697
+ .intro-card-actions > * {
698
+ flex: 0 0 auto !important;
699
+ width: auto !important;
700
+ }
701
+ .intro-card-actions button {
702
+ min-height: 30px !important;
703
+ padding: 0 12px !important;
704
+ font-size: 12.5px !important;
705
+ white-space: normal !important;
706
+ line-height: 1.15 !important;
707
+ }
708
+ .intro-dots {
709
+ display: flex;
710
+ align-items: center;
711
+ gap: 5px;
712
+ flex: 0 0 auto;
713
+ }
714
+ .intro-dots i {
715
+ width: 8px;
716
+ height: 8px;
717
+ display: block;
718
+ background: color-mix(in srgb, var(--color-text) 18%, transparent);
719
+ }
720
+ .intro-dots i.on {
721
+ background: var(--color-accent);
722
+ }
723
+
724
  /* ── responsive ───────────────────────────────────────────────────────── */
725
  @media (max-width: 1000px) {
726
  .app-shell { display: block !important; }
tests/test_core.py CHANGED
@@ -128,6 +128,35 @@ def test_model_forward_probabilistic_shape() -> None:
128
  )
129
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  def test_training_artifact_roundtrip(tmp_path) -> None:
132
  workspace = _tiny_workspace()
133
  result = train(
@@ -161,6 +190,39 @@ def test_training_artifact_roundtrip(tmp_path) -> None:
161
  )
162
 
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  def test_zerogpu_duration_estimator_is_bounded_and_scales() -> None:
165
  from app import estimate_training_duration
166
 
 
128
  )
129
 
130
 
131
+ def test_duplicate_embedding_rows_do_not_break_pathway_attachment() -> None:
132
+ branch = BranchPriors(
133
+ input_genes=["A", "B"],
134
+ hidden_genes=["A", "B", "C"],
135
+ biological_mask=np.ones((2, 3), dtype=np.float32),
136
+ )
137
+ embeddings = pd.DataFrame(
138
+ np.arange(16, dtype=np.float32).reshape(4, 4),
139
+ index=["A", "B", "B", "C"],
140
+ )
141
+ pathway_mapping = pd.DataFrame(
142
+ {
143
+ "SYMBOL": ["A", "B", "C"],
144
+ "PathwayID": ["hsa1", "hsa1", "hsa2"],
145
+ }
146
+ )
147
+
148
+ attach_embeddings_and_pathways(
149
+ branch,
150
+ embeddings,
151
+ pathway_mapping,
152
+ precomputed_significant=True,
153
+ )
154
+
155
+ assert branch.embeddings.shape[0] == len(branch.hidden_genes)
156
+ assert branch.biological_mask.shape[1] == len(branch.hidden_genes)
157
+ assert branch.pathway_mask.shape[0] == len(branch.hidden_genes)
158
+
159
+
160
  def test_training_artifact_roundtrip(tmp_path) -> None:
161
  workspace = _tiny_workspace()
162
  result = train(
 
190
  )
191
 
192
 
193
+ def test_prediction_can_score_prepared_workspace_without_uploads() -> None:
194
+ from app import run_prediction
195
+
196
+ workspace = _tiny_workspace()
197
+ result = train(
198
+ workspace,
199
+ Hyperparameters(
200
+ epochs=3,
201
+ batch_size=8,
202
+ projection_dim=4,
203
+ fusion_dim=3,
204
+ dropout=0.0,
205
+ early_stopping_patience=3,
206
+ ),
207
+ )
208
+
209
+ _, status, output, _, download_path, predict_meta = run_prediction(
210
+ result.bundle,
211
+ workspace,
212
+ None,
213
+ False,
214
+ True,
215
+ None,
216
+ None,
217
+ {},
218
+ )
219
+
220
+ assert "Predicted 48 samples" in status
221
+ assert len(output) == len(workspace.labels)
222
+ assert download_path is not None
223
+ assert predict_meta["n_samples"] == len(workspace.labels)
224
+
225
+
226
  def test_zerogpu_duration_estimator_is_bounded_and_scales() -> None:
227
  from app import estimate_training_duration
228