aetherscan 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,709 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Live dashboard for an Aetherscan pipeline run, read straight from its SQLite DB.
4
+
5
+ Renders, in one auto-refreshing page, every run metric that is reconstructable from the DB —
6
+ resource utilization (CPU/RAM/GPU), beta-VAE loss + stability curves, RF eval metrics
7
+ (accuracy/AUC/AP/Brier tiles, confusion heatmaps, ensemble curve, confidence quantiles), the
8
+ signal-injection stats suite, a live latent-space scatter, the stage timeline (PR #134), and
9
+ inference candidate stats — plus a gallery of every saved plot PNG (RF diagnostics, latent
10
+ traversals, inference figures) as it appears on disk.
11
+
12
+ It is STANDALONE — no `aetherscan` imports (like utils/benchmark_report.py), only stdlib sqlite3 +
13
+ numpy + pandas + plotly + streamlit — so streamlit can execute it directly and it runs against a
14
+ live run's DB or one fetched from a cluster with utils/fetch_run_outputs.sh. It ships inside the
15
+ package (src/aetherscan/dashboard.py) so every install method (pip `aetherscan[dashboard]`, the
16
+ container, or a source checkout) can auto-launch it. main.py auto-launches it (--no-dashboard to
17
+ opt out); to run it by hand against a saved DB, point streamlit at the installed module file:
18
+
19
+ streamlit run "$(python -c 'import aetherscan, os; \
20
+ print(os.path.join(os.path.dirname(aetherscan.__file__), "dashboard.py"))')" \
21
+ -- --db-path /path/to/aetherscan.db --tag train_20260101_120000
22
+
23
+ To watch a run on a cluster, SSH-forward the port: ssh -L 8501:localhost:8501 blpc3
24
+
25
+ Read-only (mode=ro); the pipeline's writer-thread journaling makes concurrent reads safe. The data
26
+ layer (load_* / parse / pca helpers) is pure and unit-tested against a synthetic DB; the Streamlit
27
+ UI (render*) is a thin rendering shell that imports plotly/streamlit lazily.
28
+
29
+ Auto-refresh uses a blocking `time.sleep(refresh) + st.rerun()` — deliberately version-robust (works
30
+ on any Streamlit), at the cost of freezing sidebar/tooltip interaction during the sleep window. For
31
+ a smoother non-blocking refresh, `pip install streamlit-autorefresh` (or use `st.fragment(run_every=)`
32
+ on Streamlit >= 1.33) and swap it in.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import os
40
+ import sqlite3
41
+
42
+ import numpy as np
43
+ import pandas as pd
44
+
45
+ # --------------------------------------------------------------------------- #
46
+ # Data layer — pure, unit-testable, no Streamlit #
47
+ # --------------------------------------------------------------------------- #
48
+
49
+ # Non-superseded filter reused across the supersede-aware tables
50
+ _ALIVE = "COALESCE(superseded, 0) = 0"
51
+
52
+ # beta-VAE stat_names split into the two training figures (train.py loss curves / stability)
53
+ _LOSS_STATS = [
54
+ "total_loss",
55
+ "reconstruction_loss",
56
+ "kl_loss",
57
+ "true_loss",
58
+ "false_loss",
59
+ "learning_rate",
60
+ ]
61
+ _STABILITY_STATS = ["clipping_rate", "gradient_norm_mean", "gradient_norm_std", "gradient_norm_max"]
62
+
63
+
64
+ def connect_ro(db_path: str) -> sqlite3.Connection:
65
+ """Open the DB strictly read-only (won't create/lock a missing file for writing)."""
66
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
67
+ conn.row_factory = sqlite3.Row
68
+ return conn
69
+
70
+
71
+ def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
72
+ return (
73
+ conn.execute(
74
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
75
+ ).fetchone()
76
+ is not None
77
+ )
78
+
79
+
80
+ def list_tags(conn: sqlite3.Connection) -> list[str]:
81
+ """All run tags present anywhere in the DB, sorted."""
82
+ tags: set[str] = set()
83
+ for table in ("pipeline_stages", "system_resources", "training_stats", "inference_cadences"):
84
+ try:
85
+ rows = conn.execute(
86
+ f"SELECT DISTINCT tag FROM {table} WHERE tag IS NOT NULL" # noqa: S608 (fixed names)
87
+ ).fetchall()
88
+ tags.update(r[0] for r in rows)
89
+ except sqlite3.OperationalError:
90
+ continue # table absent in an older schema
91
+ return sorted(tags)
92
+
93
+
94
+ def load_resources(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
95
+ """system_resources rows for `tag` (timestamp, resource_type, resource_name, value, unit)."""
96
+ return pd.read_sql_query(
97
+ "SELECT timestamp, resource_type, resource_name, value, unit "
98
+ "FROM system_resources WHERE tag = ? ORDER BY timestamp",
99
+ conn,
100
+ params=(tag,),
101
+ )
102
+
103
+
104
+ def load_training_stats(conn: sqlite3.Connection, tag: str, stats: list[str]) -> pd.DataFrame:
105
+ """Non-superseded beta_vae training_stats for `tag` limited to `stats` (and their val_
106
+ counterparts), ordered so curves plot in run order (round, epoch, timestamp)."""
107
+ wanted = list(stats) + [f"val_{s}" for s in stats]
108
+ placeholders = ",".join("?" * len(wanted))
109
+ df = pd.read_sql_query(
110
+ "SELECT timestamp, stat_name, value, round_number, epoch_number "
111
+ f"FROM training_stats WHERE tag = ? AND model_name = 'beta_vae' AND {_ALIVE} "
112
+ f"AND stat_name IN ({placeholders}) " # noqa: S608 (placeholders are bound params)
113
+ "ORDER BY round_number, epoch_number, timestamp",
114
+ conn,
115
+ params=(tag, *wanted),
116
+ )
117
+ return df
118
+
119
+
120
+ def load_rf_stats(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
121
+ """Non-superseded model_name='rf' training_stats for `tag` (timestamp, stat_name, value,
122
+ round_number, epoch_number). Duplicate (stat_name, epoch_number) rows — an rf_plots retry
123
+ re-writing the ensemble curve, or a reused tag's stale scalars — collapse last-write-wins
124
+ (rows are ordered by timestamp; scalar rows share a NULL epoch_number, which pandas
125
+ treats as equal when de-duplicating)."""
126
+ df = pd.read_sql_query(
127
+ "SELECT timestamp, stat_name, value, round_number, epoch_number "
128
+ f"FROM training_stats WHERE tag = ? AND model_name = 'rf' AND {_ALIVE} "
129
+ "ORDER BY timestamp",
130
+ conn,
131
+ params=(tag,),
132
+ )
133
+ if not df.empty:
134
+ df = df.drop_duplicates(subset=["stat_name", "epoch_number"], keep="last").reset_index(
135
+ drop=True
136
+ )
137
+ return df
138
+
139
+
140
+ def rf_confusion_matrices(rf: pd.DataFrame) -> tuple[pd.DataFrame | None, pd.DataFrame | None]:
141
+ """Shape load_rf_stats rows into the RF tab's two confusion matrices: the binary 2x2
142
+ (actual x predicted, from confusion_{tn,fp,fn,tp}) and the sub-type x predicted-class
143
+ counts (from confusion_{subtype}_pred_{false,true}). Either is None when its cell rows
144
+ are absent."""
145
+ if rf.empty:
146
+ return None, None
147
+ scalars = dict(zip(rf["stat_name"], rf["value"], strict=False))
148
+
149
+ cells = [scalars.get(f"confusion_{c}") for c in ("tn", "fp", "fn", "tp")]
150
+ binary = None
151
+ if None not in cells:
152
+ binary = pd.DataFrame(
153
+ [[cells[0], cells[1]], [cells[2], cells[3]]],
154
+ index=["actual false", "actual true"],
155
+ columns=["pred false", "pred true"],
156
+ )
157
+
158
+ subtype = None
159
+ sub = rf[rf["stat_name"].str.match(r"^confusion_.+_pred_(true|false)$")].copy()
160
+ if not sub.empty:
161
+ parts = sub["stat_name"].str.extract(r"^confusion_(.+)_pred_(true|false)$")
162
+ sub["subtype"] = parts[0]
163
+ sub["pred"] = "pred " + parts[1]
164
+ subtype = (
165
+ sub.pivot_table(index="subtype", columns="pred", values="value", aggfunc="last")
166
+ .reindex(columns=["pred false", "pred true"])
167
+ .sort_index()
168
+ )
169
+ return binary, subtype
170
+
171
+
172
+ def load_injection_stats(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
173
+ """Non-superseded injection_stats for `tag` (stat_name, value, signal_type, injection_stage,
174
+ round_number, is_finite, slope_clamped, timestamp)."""
175
+ return pd.read_sql_query(
176
+ "SELECT timestamp, stat_name, value, signal_type, injection_stage, round_number, "
177
+ "is_finite, slope_clamped "
178
+ f"FROM injection_stats WHERE tag = ? AND {_ALIVE} ORDER BY timestamp",
179
+ conn,
180
+ params=(tag,),
181
+ )
182
+
183
+
184
+ def load_latent_snapshots_latest(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
185
+ """The most recent latent_snapshots frame for `tag` (all rows sharing the max
186
+ round/epoch/step), as (signal_type, latent_vector[JSON]). Empty if none."""
187
+ if not _table_exists(conn, "latent_snapshots"):
188
+ return pd.DataFrame(columns=["signal_type", "latent_vector"])
189
+ key = conn.execute(
190
+ "SELECT round_number, epoch_number, step_number FROM latent_snapshots "
191
+ f"WHERE tag = ? AND {_ALIVE} "
192
+ "ORDER BY round_number DESC, epoch_number DESC, step_number DESC LIMIT 1",
193
+ (tag,),
194
+ ).fetchone()
195
+ if key is None:
196
+ return pd.DataFrame(columns=["signal_type", "latent_vector"])
197
+ return pd.read_sql_query(
198
+ "SELECT signal_type, latent_vector FROM latent_snapshots "
199
+ f"WHERE tag = ? AND {_ALIVE} AND round_number = ? AND epoch_number = ? AND step_number = ?",
200
+ conn,
201
+ params=(tag, key["round_number"], key["epoch_number"], key["step_number"]),
202
+ )
203
+
204
+
205
+ def load_inference_results(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
206
+ """Non-superseded inference_results for `tag` (prediction, confidence, target, band,
207
+ frequency_mhz). Empty frame if the table is absent."""
208
+ if not _table_exists(conn, "inference_results"):
209
+ return pd.DataFrame(columns=["prediction", "confidence", "target", "band", "frequency_mhz"])
210
+ return pd.read_sql_query(
211
+ "SELECT prediction, confidence, target, band, frequency_mhz "
212
+ f"FROM inference_results WHERE tag = ? AND {_ALIVE}",
213
+ conn,
214
+ params=(tag,),
215
+ )
216
+
217
+
218
+ def load_inference_cadences(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
219
+ """Non-superseded inference_cadences manifest rows for `tag` (status, duration_s, n_stamps,
220
+ n_candidates). Empty frame if the table is absent."""
221
+ if not _table_exists(conn, "inference_cadences"):
222
+ return pd.DataFrame(columns=["status", "duration_s", "n_stamps", "n_candidates"])
223
+ return pd.read_sql_query(
224
+ "SELECT status, duration_s, n_stamps, n_candidates "
225
+ f"FROM inference_cadences WHERE tag = ? AND {_ALIVE}",
226
+ conn,
227
+ params=(tag,),
228
+ )
229
+
230
+
231
+ def load_stages(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
232
+ """pipeline_stages spans for `tag` (stage, start_time, end_time, duration_s, metadata) plus a
233
+ derived `depth` (dot-name component count), ordered by start. Empty if the table is absent."""
234
+ if not _table_exists(conn, "pipeline_stages"):
235
+ return pd.DataFrame(columns=["stage", "start_time", "end_time", "duration_s", "metadata"])
236
+ df = pd.read_sql_query(
237
+ "SELECT stage, start_time, end_time, duration_s, metadata "
238
+ "FROM pipeline_stages WHERE tag = ? ORDER BY start_time",
239
+ conn,
240
+ params=(tag,),
241
+ )
242
+ if not df.empty:
243
+ df["depth"] = df["stage"].str.split(".").str.len()
244
+ return df
245
+
246
+
247
+ def parse_latent_matrix(snapshots: pd.DataFrame) -> tuple[np.ndarray, list[str]]:
248
+ """Parse a latent_snapshots frame's JSON latent_vector column into an (n, d) float array +
249
+ the matching signal_type labels. Rows whose vector is malformed, ragged, or non-finite
250
+ (NaN/inf — json.loads accepts these and they blow up the downstream SVD) are dropped."""
251
+ vecs, labels = [], []
252
+ for _, row in snapshots.iterrows():
253
+ try:
254
+ v = json.loads(row["latent_vector"])
255
+ except (TypeError, ValueError):
256
+ continue
257
+ if isinstance(v, list) and v:
258
+ vecs.append(v)
259
+ labels.append(row["signal_type"])
260
+ if not vecs:
261
+ return np.empty((0, 0)), []
262
+ width = len(vecs[0])
263
+ keep = [(v, s) for v, s in zip(vecs, labels, strict=False) if len(v) == width]
264
+ mat = np.array([v for v, _ in keep], dtype=float)
265
+ labels_keep = [s for _, s in keep]
266
+ if mat.size:
267
+ finite = np.isfinite(mat).all(axis=1)
268
+ mat = mat[finite]
269
+ labels_keep = [s for s, f in zip(labels_keep, finite, strict=False) if f]
270
+ return mat, labels_keep
271
+
272
+
273
+ def pca_2d(matrix: np.ndarray) -> np.ndarray:
274
+ """Project (n, d) -> (n, 2) via mean-centered SVD (numpy-only PCA; no sklearn). Returns a
275
+ (n, 2) array; if d < 2 the missing component(s) are zero-filled."""
276
+ if matrix.size == 0 or matrix.shape[0] == 0:
277
+ return np.empty((0, 2))
278
+ centered = matrix - matrix.mean(axis=0, keepdims=True)
279
+ # economy SVD; right singular vectors are the principal axes
280
+ _, _, vt = np.linalg.svd(centered, full_matrices=False)
281
+ comps = vt[:2] if vt.shape[0] >= 2 else vt
282
+ proj = centered @ comps.T
283
+ if proj.shape[1] == 1: # degenerate d==1
284
+ proj = np.hstack([proj, np.zeros((proj.shape[0], 1))])
285
+ return proj
286
+
287
+
288
+ def list_png_artifacts(plots_dir: str, limit: int = 60) -> list[dict]:
289
+ """Every *.png/*.gif under plots_dir (recursive), newest first: {path, name, rel, mtime}.
290
+
291
+ Containment: plots_dir is realpath-resolved and every returned file's realpath is verified to
292
+ stay within that root, so a symlink inside the tree can't make the gallery serve a file from
293
+ outside the run's plots directory. (The dashboard is a single-operator local tool pointed at
294
+ their own run via the launch args, but this keeps the file-serving surface tightly bounded.)
295
+ """
296
+ if not plots_dir or not os.path.isdir(plots_dir):
297
+ return []
298
+ base = os.path.realpath(plots_dir)
299
+ found = []
300
+ for root, _dirs, files in os.walk(base):
301
+ for f in files:
302
+ if not f.lower().endswith((".png", ".gif")):
303
+ continue
304
+ real = os.path.realpath(os.path.join(root, f))
305
+ if real != base and not real.startswith(base + os.sep):
306
+ continue # symlink escaping the plots tree — never serve it
307
+ try:
308
+ mtime = os.path.getmtime(real)
309
+ except OSError:
310
+ continue
311
+ found.append(
312
+ {"path": real, "name": f, "rel": os.path.relpath(real, base), "mtime": mtime}
313
+ )
314
+ found.sort(key=lambda d: d["mtime"], reverse=True)
315
+ return found[:limit]
316
+
317
+
318
+ def default_plots_dir(db_path: str) -> str:
319
+ """Infer {output_path}/plots from the DB path (…/{output_path}/db/aetherscan.db)."""
320
+ db_dir = os.path.dirname(os.path.abspath(db_path)) # …/db
321
+ return os.path.join(os.path.dirname(db_dir), "plots")
322
+
323
+
324
+ def run_summary(resources: pd.DataFrame, stages: pd.DataFrame) -> dict:
325
+ """Headline dict: wall-clock span, #stages, latest stage, peak system RAM %."""
326
+ t_start: float | None = None
327
+ t_end: float | None = None
328
+ if not stages.empty:
329
+ t_start, t_end = stages["start_time"].min(), stages["end_time"].max()
330
+ elif not resources.empty:
331
+ t_start, t_end = resources["timestamp"].min(), resources["timestamp"].max()
332
+ wall = (t_end - t_start) if t_start is not None else 0.0
333
+
334
+ latest_stage = None
335
+ if not stages.empty:
336
+ latest_stage = stages.sort_values("start_time")["stage"].iloc[-1]
337
+
338
+ peak_ram = None
339
+ if not resources.empty:
340
+ ram = resources[
341
+ (resources["resource_type"] == "ram") & (resources["resource_name"] == "system_total")
342
+ ]
343
+ if not ram.empty:
344
+ peak_ram = float(ram["value"].max())
345
+
346
+ return {
347
+ "wall_s": float(wall),
348
+ "n_stages": int(len(stages)),
349
+ "latest_stage": latest_stage,
350
+ "peak_ram_pct": peak_ram,
351
+ }
352
+
353
+
354
+ # --------------------------------------------------------------------------- #
355
+ # Streamlit UI — thin shell over the data layer (imports plotly/streamlit lazily)
356
+ # --------------------------------------------------------------------------- #
357
+
358
+
359
+ def _parse_args() -> argparse.Namespace:
360
+ ap = argparse.ArgumentParser(description="Live Aetherscan run dashboard")
361
+ ap.add_argument("--db-path", required=True, help="Path to aetherscan.db")
362
+ ap.add_argument("--tag", default=None, help="Run tag (else pick in the sidebar)")
363
+ ap.add_argument(
364
+ "--plots-dir", default=None, help="Plots dir (default: inferred from --db-path)"
365
+ )
366
+ ap.add_argument("--refresh", type=int, default=10, help="Auto-refresh seconds (0 = off)")
367
+ args, _ = ap.parse_known_args() # streamlit injects extra args after `--`
368
+ return args
369
+
370
+
371
+ def _fmt_duration(seconds: float) -> str:
372
+ seconds = int(seconds)
373
+ h, rem = divmod(seconds, 3600)
374
+ m, s = divmod(rem, 60)
375
+ if h:
376
+ return f"{h}h{m:02d}m"
377
+ if m:
378
+ return f"{m}m{s:02d}s"
379
+ return f"{s}s"
380
+
381
+
382
+ def render(args: argparse.Namespace) -> None: # pragma: no cover - requires Streamlit runtime
383
+ import plotly.express as px # noqa: PLC0415 (lazy so the data layer imports without plotly)
384
+ import plotly.graph_objects as go # noqa: PLC0415
385
+ import streamlit as st # noqa: PLC0415
386
+
387
+ st.set_page_config(page_title="Aetherscan live", layout="wide")
388
+
389
+ with st.sidebar:
390
+ st.header("Aetherscan run")
391
+ # DB + plots paths come from the launch args only (NOT browser-editable text inputs), so a
392
+ # hosted instance can't be repointed at arbitrary filesystem paths from the browser.
393
+ db_path = args.db_path
394
+ st.caption(f"DB: `{db_path}`")
395
+ try:
396
+ conn = connect_ro(db_path)
397
+ except sqlite3.OperationalError as e:
398
+ st.error(f"Cannot open DB read-only: {e}")
399
+ st.stop()
400
+ tags = list_tags(conn)
401
+ if not tags:
402
+ conn.close()
403
+ st.warning("No run tags found in this DB yet.")
404
+ st.stop()
405
+ default_idx = tags.index(args.tag) if args.tag in tags else len(tags) - 1
406
+ tag = st.selectbox("Tag", tags, index=default_idx)
407
+ refresh = st.number_input("Auto-refresh (s, 0=off)", 0, 600, value=args.refresh)
408
+ plots_dir = args.plots_dir or default_plots_dir(db_path)
409
+ st.caption(f"Plots: `{plots_dir}`")
410
+
411
+ try:
412
+ resources = load_resources(conn, tag)
413
+ stages = load_stages(conn, tag)
414
+ summary = run_summary(resources, stages)
415
+
416
+ st.title(f"Aetherscan — {tag}")
417
+ c1, c2, c3, c4 = st.columns(4)
418
+ c1.metric("Wall clock", _fmt_duration(summary["wall_s"]))
419
+ c2.metric("Stage spans", summary["n_stages"])
420
+ c3.metric("Latest stage", summary["latest_stage"] or "—")
421
+ c4.metric(
422
+ "Peak sys RAM", f"{summary['peak_ram_pct']:.0f}%" if summary["peak_ram_pct"] else "—"
423
+ )
424
+
425
+ tabs = st.tabs(
426
+ [
427
+ "Training",
428
+ "RF",
429
+ "Injection",
430
+ "Latent",
431
+ "Resources",
432
+ "Stages",
433
+ "Inference",
434
+ "All plots (PNG)",
435
+ ]
436
+ )
437
+
438
+ # --- Training loss + stability ----------------------------------------
439
+ with tabs[0]:
440
+ loss = load_training_stats(conn, tag, _LOSS_STATS)
441
+ stab = load_training_stats(conn, tag, _STABILITY_STATS)
442
+ if loss.empty and stab.empty:
443
+ st.caption("No beta_vae training_stats yet.")
444
+ for title, df in (("Loss curves", loss), ("Training stability", stab)):
445
+ if df.empty:
446
+ continue
447
+ st.subheader(title)
448
+ d = df.copy()
449
+ d["kind"] = d["stat_name"].str.replace("^val_", "", regex=True)
450
+ d["split"] = np.where(d["stat_name"].str.startswith("val_"), "val", "train")
451
+ for kind in sorted(d["kind"].unique()):
452
+ sub = d[d["kind"] == kind].copy()
453
+ sub["step"] = range(len(sub))
454
+ fig = px.line(sub, x="step", y="value", color="split", title=kind)
455
+ fig.update_layout(height=240, margin={"l": 10, "r": 10, "t": 40, "b": 10})
456
+ st.plotly_chart(fig, use_container_width=True)
457
+
458
+ # --- RF eval metrics ---------------------------------------------------
459
+ with tabs[1]:
460
+ rf = load_rf_stats(conn, tag)
461
+ if rf.empty:
462
+ st.caption("No rf training_stats yet (written when the RF stage completes).")
463
+ else:
464
+ scalars = dict(zip(rf["stat_name"], rf["value"], strict=False))
465
+ tiles = [
466
+ ("Val accuracy", "val_accuracy"),
467
+ ("ROC-AUC", "val_roc_auc"),
468
+ ("Avg precision", "val_average_precision"),
469
+ ("Brier score", "val_brier_score"),
470
+ ]
471
+ cols = st.columns(len(tiles))
472
+ for col, (label, name) in zip(cols, tiles, strict=False):
473
+ v = scalars.get(name)
474
+ col.metric(label, f"{v:.4f}" if v is not None else "—")
475
+ thr = scalars.get("classification_threshold")
476
+ if thr is not None:
477
+ st.caption(
478
+ f"Accuracy + confusion cells at deployment threshold {thr:g}; "
479
+ "the ensemble curve thresholds at 0.5."
480
+ )
481
+
482
+ binary_cm, subtype_cm = rf_confusion_matrices(rf)
483
+ left, right = st.columns(2)
484
+ for col, cm, title in (
485
+ (left, binary_cm, "Binary confusion (val)"),
486
+ (right, subtype_cm, "Sub-type × prediction (val)"),
487
+ ):
488
+ if cm is None:
489
+ continue
490
+ with col:
491
+ st.subheader(title)
492
+ fig = px.imshow(
493
+ cm.to_numpy(),
494
+ x=list(cm.columns),
495
+ y=list(cm.index),
496
+ text_auto=".0f",
497
+ color_continuous_scale="Blues",
498
+ )
499
+ fig.update_layout(
500
+ height=300,
501
+ margin={"l": 10, "r": 10, "t": 10, "b": 10},
502
+ coloraxis_showscale=False,
503
+ )
504
+ st.plotly_chart(fig, use_container_width=True)
505
+
506
+ acc = rf[rf["stat_name"].str.startswith("val_accuracy_")].copy()
507
+ if not acc.empty:
508
+ acc["subtype"] = acc["stat_name"].str.replace("val_accuracy_", "", regex=False)
509
+ fig = px.bar(acc, x="subtype", y="value", title="Per-sub-type val accuracy")
510
+ fig.update_layout(height=260, margin={"l": 10, "r": 10, "t": 40, "b": 10})
511
+ st.plotly_chart(fig, use_container_width=True)
512
+
513
+ curve = rf[rf["stat_name"] == "ensemble_val_accuracy"].sort_values("epoch_number")
514
+ if curve.empty:
515
+ st.caption("No ensemble_val_accuracy series yet (written by rf_plots).")
516
+ else:
517
+ fig = px.line(
518
+ curve, x="epoch_number", y="value", title="Ensemble accuracy vs tree count"
519
+ )
520
+ fig.update_layout(
521
+ height=280,
522
+ margin={"l": 10, "r": 10, "t": 40, "b": 10},
523
+ xaxis_title="number of trees",
524
+ yaxis_title="val accuracy",
525
+ )
526
+ st.plotly_chart(fig, use_container_width=True)
527
+
528
+ quant = rf[rf["stat_name"].str.startswith("val_proba_q")].copy()
529
+ if not quant.empty:
530
+ quant["quantile"] = quant["stat_name"].str.replace(
531
+ "val_proba_q", "p", regex=False
532
+ )
533
+ fig = px.bar(
534
+ quant.sort_values("quantile"),
535
+ x="quantile",
536
+ y="value",
537
+ title="Val P(true) quantiles",
538
+ )
539
+ fig.update_layout(height=260, margin={"l": 10, "r": 10, "t": 40, "b": 10})
540
+ st.plotly_chart(fig, use_container_width=True)
541
+ st.caption(
542
+ "Richer RF figures (SHAP suite, decision boundary, calibration) live under "
543
+ "All plots (PNG)."
544
+ )
545
+
546
+ # --- Injection stats ---------------------------------------------------
547
+ with tabs[2]:
548
+ inj = load_injection_stats(conn, tag)
549
+ if inj.empty:
550
+ st.caption("No injection_stats yet.")
551
+ else:
552
+ st.subheader("Injection stability (per round)")
553
+ stability = (
554
+ inj.assign(nonfinite=1 - inj["is_finite"].fillna(1))
555
+ .groupby("round_number")[["nonfinite", "slope_clamped"]]
556
+ .mean()
557
+ .reset_index()
558
+ )
559
+ fig = px.line(
560
+ stability,
561
+ x="round_number",
562
+ y=["nonfinite", "slope_clamped"],
563
+ title="mean non-finite / slope-clamp rate",
564
+ markers=True,
565
+ )
566
+ fig.update_layout(height=260, margin={"l": 10, "r": 10, "t": 40, "b": 10})
567
+ st.plotly_chart(fig, use_container_width=True)
568
+
569
+ st.subheader("Injected-signal / intensity stat distributions")
570
+ stat = st.selectbox("stat_name", sorted(inj["stat_name"].unique()))
571
+ sub = inj[(inj["stat_name"] == stat) & inj["value"].notna()]
572
+ color = "signal_type" if sub["signal_type"].notna().any() else None
573
+ fig = px.histogram(
574
+ sub, x="value", color=color, barmode="overlay", nbins=60, title=stat
575
+ )
576
+ fig.update_layout(height=300, margin={"l": 10, "r": 10, "t": 40, "b": 10})
577
+ st.plotly_chart(fig, use_container_width=True)
578
+
579
+ # --- Latent scatter (live PCA of the latest snapshot) ------------------
580
+ with tabs[3]:
581
+ snaps = load_latent_snapshots_latest(conn, tag)
582
+ mat, labels = parse_latent_matrix(snaps)
583
+ if mat.shape[0] == 0:
584
+ st.caption("No latent_snapshots yet.")
585
+ else:
586
+ proj = pca_2d(mat)
587
+ df = pd.DataFrame({"pc1": proj[:, 0], "pc2": proj[:, 1], "signal_type": labels})
588
+ st.subheader(
589
+ f"Latent space — latest snapshot (PCA of {mat.shape[0]}×{mat.shape[1]})"
590
+ )
591
+ fig = px.scatter(df, x="pc1", y="pc2", color="signal_type", opacity=0.7)
592
+ fig.update_layout(height=520, margin={"l": 10, "r": 10, "t": 10, "b": 10})
593
+ st.plotly_chart(fig, use_container_width=True)
594
+ st.caption(
595
+ "Cheap PCA projection; the pipeline's saved UMAP animation is under All plots."
596
+ )
597
+
598
+ # --- Resource utilization ---------------------------------------------
599
+ with tabs[4]:
600
+ if resources.empty:
601
+ st.caption("No system_resources yet.")
602
+ else:
603
+ r = resources.copy()
604
+ r["t_min"] = (r["timestamp"] - r["timestamp"].min()) / 60.0
605
+ r["series"] = r["resource_type"] + ":" + r["resource_name"]
606
+ for rtype in ("cpu", "ram", "gpu"):
607
+ sub = r[r["resource_type"] == rtype]
608
+ if sub.empty:
609
+ continue
610
+ fig = px.line(sub, x="t_min", y="value", color="series", title=rtype.upper())
611
+ fig.update_layout(
612
+ height=260,
613
+ margin={"l": 10, "r": 10, "t": 40, "b": 10},
614
+ xaxis_title="minutes",
615
+ yaxis_title="%",
616
+ )
617
+ st.plotly_chart(fig, use_container_width=True)
618
+
619
+ # --- Stage timeline ----------------------------------------------------
620
+ with tabs[5]:
621
+ if stages.empty:
622
+ st.caption("No pipeline_stages yet (needs PR #134's benchmarking layer).")
623
+ else:
624
+ s = stages.copy()
625
+ t0 = s["start_time"].min()
626
+ s["start_min"] = (s["start_time"] - t0) / 60.0
627
+ s["dur_min"] = (s["end_time"] - s["start_time"]) / 60.0
628
+ s["family"] = s["stage"].str.split(".").str[0]
629
+ palette = px.colors.qualitative.Plotly
630
+ fig = go.Figure()
631
+ for i, fam in enumerate(sorted(s["family"].unique())):
632
+ sub = s[s["family"] == fam]
633
+ fig.add_bar(
634
+ x=sub["dur_min"],
635
+ base=sub["start_min"],
636
+ y=sub["stage"],
637
+ orientation="h",
638
+ name=fam,
639
+ marker_color=palette[i % len(palette)],
640
+ customdata=sub[["duration_s", "depth"]],
641
+ hovertemplate="%{y}<br>%{customdata[0]:.1f}s (depth %{customdata[1]})<extra></extra>",
642
+ )
643
+ fig.update_yaxes(autorange="reversed")
644
+ fig.update_xaxes(title="minutes since first stage")
645
+ fig.update_layout(
646
+ height=max(300, 22 * s["stage"].nunique()),
647
+ margin={"l": 10, "r": 10, "t": 20, "b": 10},
648
+ barmode="overlay",
649
+ )
650
+ st.plotly_chart(fig, use_container_width=True)
651
+
652
+ # --- Inference candidates ---------------------------------------------
653
+ with tabs[6]:
654
+ results = load_inference_results(conn, tag)
655
+ cadences = load_inference_cadences(conn, tag)
656
+ if results.empty and cadences.empty:
657
+ st.caption("No inference_results / inference_cadences yet.")
658
+ if not results.empty:
659
+ cands = results[results["prediction"] == 1]
660
+ st.subheader(f"Candidate confidence ({len(cands)} candidates)")
661
+ if not cands.empty:
662
+ fig = px.histogram(cands, x="confidence", nbins=40)
663
+ fig.update_layout(height=240, margin={"l": 10, "r": 10, "t": 10, "b": 10})
664
+ st.plotly_chart(fig, use_container_width=True)
665
+ for dim in ("target", "band"):
666
+ if cands[dim].notna().any():
667
+ counts = cands[dim].value_counts().reset_index()
668
+ counts.columns = [dim, "candidates"]
669
+ fig = px.bar(
670
+ counts, x=dim, y="candidates", title=f"candidates per {dim}"
671
+ )
672
+ fig.update_layout(
673
+ height=240, margin={"l": 10, "r": 10, "t": 40, "b": 10}
674
+ )
675
+ st.plotly_chart(fig, use_container_width=True)
676
+ if not cadences.empty:
677
+ st.subheader("Per-cadence manifest")
678
+ st.dataframe(cadences, use_container_width=True)
679
+
680
+ # --- All plots (PNG gallery) ------------------------------------------
681
+ with tabs[7]:
682
+ pngs = list_png_artifacts(plots_dir)
683
+ if not pngs:
684
+ st.caption(f"No plot PNGs under {plots_dir} yet.")
685
+ else:
686
+ st.caption(f"{len(pngs)} figures under {plots_dir} (newest first)")
687
+ cols = st.columns(2)
688
+ for i, art in enumerate(pngs):
689
+ with cols[i % 2]:
690
+ st.image(art["path"], caption=art["rel"], use_container_width=True)
691
+
692
+ finally:
693
+ conn.close()
694
+
695
+ if refresh and refresh > 0:
696
+ # sleep()+rerun() is the version-robust live refresh (works on any Streamlit); for a
697
+ # smoother non-blocking refresh, `pip install streamlit-autorefresh` and swap it in.
698
+ import time # noqa: PLC0415
699
+
700
+ time.sleep(refresh)
701
+ st.rerun()
702
+
703
+
704
+ def main() -> None: # pragma: no cover
705
+ render(_parse_args())
706
+
707
+
708
+ if __name__ == "__main__":
709
+ main()