diff --git a/SAC_LSTM_Bot/docs/campaign_dashboard.svg b/SAC_LSTM_Bot/docs/campaign_dashboard.svg
new file mode 100644
index 0000000..f678f5f
--- /dev/null
+++ b/SAC_LSTM_Bot/docs/campaign_dashboard.svg
@@ -0,0 +1,688 @@
+
diff --git a/SAC_LSTM_Bot/docs/campaign_notebook.md b/SAC_LSTM_Bot/docs/campaign_notebook.md
index 5ded69f..a482140 100644
--- a/SAC_LSTM_Bot/docs/campaign_notebook.md
+++ b/SAC_LSTM_Bot/docs/campaign_notebook.md
@@ -321,4 +321,4 @@ SACLSTM_LR_CRITIC=1e-4 \
### Progress graphs
-Graphs live in `docs/` (`graph_eval_winrates.svg`, `graph_losses.svg`). Regenerate anytime: `python3 tools/plot_progress.py` (pure stdlib, ~0.05 s; paths overridable via argv, `--selftest` for sanity check).
+One live dashboard: `docs/campaign_dashboard.svg` (current run only — test wins, real-fight wins, losses, alpha, throughput; auto-reloads every 60 s when open in Chrome). Keep it fresh with `tools/watch_dashboard.sh` (regenerates every 60 s), or one-shot `python3 tools/plot_progress.py` (pure stdlib; paths overridable via argv, `--selftest` for sanity check).
diff --git a/SAC_LSTM_Bot/docs/graph_eval_winrates.svg b/SAC_LSTM_Bot/docs/graph_eval_winrates.svg
deleted file mode 100644
index 7c9f190..0000000
--- a/SAC_LSTM_Bot/docs/graph_eval_winrates.svg
+++ /dev/null
@@ -1,346 +0,0 @@
-
diff --git a/SAC_LSTM_Bot/docs/graph_losses.svg b/SAC_LSTM_Bot/docs/graph_losses.svg
deleted file mode 100644
index f7212a4..0000000
--- a/SAC_LSTM_Bot/docs/graph_losses.svg
+++ /dev/null
@@ -1,91 +0,0 @@
-
diff --git a/SAC_LSTM_Bot/tools/plot_progress.py b/SAC_LSTM_Bot/tools/plot_progress.py
index ed871b9..9b2274d 100644
--- a/SAC_LSTM_Bot/tools/plot_progress.py
+++ b/SAC_LSTM_Bot/tools/plot_progress.py
@@ -1,15 +1,23 @@
#!/usr/bin/env python3
-"""Plot SAC training progress from live campaign logs.
+"""Build the single self-updating SAC training dashboard.
Pure-stdlib SVG output (matplotlib not available on this box).
-Generates:
- docs/graph_eval_winrates.svg - two stacked panels: Run 3 trend (raw dots +
- rolling-10 thick line) per opponent, and
- Run 1 vs Run 3 Corners on a fraction-of-run axis
- docs/graph_losses.svg - critic/actor/alpha loss curves from training_metrics.jsonl
+Generates ONE file:
+ docs/campaign_dashboard.svg - five panels, current (v2) run only:
+ 1. test-match win % vs opponents (campaign_v2_stdout.log eval lines)
+ 2. real-fight win % per opponent (training_log.jsonl, ~100-game buckets)
+ 3. critic_loss / |actor_loss| (training_metrics.jsonl, shared log-y)
+ 4. alpha temperature (training_metrics.jsonl, linear)
+ 5. throughput, games/hour buckets (training_metrics.jsonl 'epoch' deltas;
+ training_log.jsonl has NO timestamps -
+ verified field names - and one metrics
+ row == one 10-game chunk, counts match
+ the stdout "=== Chunk N/N ===" markers)
+ plus an embedded JS snippet that reloads the page every 60 s when the SVG is
+ opened as a top-level document in Chrome.
Usage:
- python3 tools/plot_progress.py [campaign_log] [metrics_jsonl] [v1_log] [outdir]
+ python3 tools/plot_progress.py [campaign_log] [metrics_jsonl] [games_jsonl] [outdir]
All args optional; defaults relative to the SAC_LSTM_Bot/ root (parent of tools/).
python3 tools/plot_progress.py --selftest # tiny built-in sanity check
"""
@@ -19,33 +27,53 @@ import os
import re
import sys
import tempfile
-import xml.etree.ElementTree as ET
+from datetime import datetime
from pathlib import Path
+import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parent.parent
EVAL_RE = re.compile(r">>> \[eval\] win rate: (\d+)/(\d+) \(([\d.]+)%\) vs (\S+)")
-TREND_WINDOW = 10 # rolling mean shown as the thick trend line
-BUCKETS = 40 # v1 downsampling for the Run1-vs-Run3 panel
-COLORS = {"Corners": "#d62728", "Crazy": "#1f77b4", "Target": "#2ca02c"}
-V1_COLOR = "#999999"
-W, M_L, M_R, M_T, M_B = 1000, 70, 20, 40, 45 # graph 2 geometry (unchanged)
-GUIDE_H = 124 # bottom band holding the plain-English "How to read" panel
-WINRATE_GUIDE = [
- "One small dot = one test match. 0 means lost all ten fights.",
- "A thick line shows the recent trend. Up = learning.",
- "Watch the three colors. Red = Corners. Blue = Crazy. Green = Target.",
- "Gray line = old run. Red line = new run. Same enemy.",
- "If red ends higher than gray, the new training worked.",
+TREND_WINDOW = 10 # rolling mean shown as the thick trend line (panel 1)
+GAME_BUCKET = 100 # games per bucket, real-fight panel
+RATE_BUCKET = 20 # metric intervals per throughput bucket (~200 games)
+GAMES_PER_ROW = 10 # one training_metrics.jsonl row per 10-round chunk
+COLORS = {"Corners": "#d62728", "Crazy": "#1f77b4", "Target": "#2ca02c",
+ "RamFire": "#ff7f0e", "SacTwin": "#9467bd"}
+CRITIC_C, ACTOR_C = "#1f77b4", "#ff7f0e"
+RELOAD_JS = ('')
+W, H = 1400, 2000
+TITLE_H, GUIDE_H = 80, 180
+# rows: (header_y, panel_top_y, panel_bottom_y, x_left, x_right)
+C1_L, C1_R = 70, 697
+C2_L, C2_R = 747, 1375
+ROWS = {
+ 1: (100, 120, 720, C1_L, C1_R),
+ 2: (100, 120, 720, C2_L, C2_R),
+ 3: (940, 958, 1428, C1_L, C1_R),
+ 4: (940, 958, 1428, C2_L, C2_R),
+ 5: (1600, 1618, 1780, C1_L, C2_R),
+}
+PANEL_TITLES = [
+ "Test matches - win % vs opponents",
+ "Real fights - win % per opponent",
+ "Training losses (log scale)",
+ "Alpha temperature",
+ "Throughput - games per hour",
]
-LOSSES_GUIDE = [
- "These are internal error numbers. They are not game scores.",
- "Critic and actor panels: big spikes are normal early.",
- "Steady growth forever is bad.",
- "Alpha near 1 = bot still experimenting.",
- "If alpha falls too fast, the bot freezes its habits.",
+GUIDE_LINES = [
+ "Test matches: dots are single fights, thick line shows trend.",
+ "Real battles only. Rising lines mean the bot improves.",
+ "Loss spikes are normal early; endless growth is bad.",
+ "Alpha high means experimenting; falling too fast freezes habits.",
+ "Throughput flat is healthy; dips mean something slowed.",
+ "This file reloads itself in Chrome every sixty seconds.",
+ "Regenerate anytime with tools/watch_dashboard.sh or the python command.",
]
+# ---------- parsing ----------
+
def parse_eval_series(path):
"""Return {opponent: [win% per eval, in file order]}."""
series = {}
@@ -81,6 +109,39 @@ def parse_metrics(path):
return rows
+def parse_games(path):
+ """Return [(opponent, won_bool)] for type=='game' rows, skipping junk."""
+ out = []
+ if not path.is_file():
+ print(f"[skip] games log not found: {path}")
+ return out
+ for line in path.read_text(errors="replace").splitlines():
+ try:
+ r = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if r.get("type") != "game":
+ continue
+ opp, win = r.get("opponent"), r.get("win")
+ if isinstance(opp, str) and isinstance(win, bool):
+ out.append((opp, win))
+ return out
+
+
+def metric_col(rows, key, positive=False):
+ """[(index, value)] for float-parseable rows; abs() applied; optional >0 filter."""
+ out = []
+ for i, r in enumerate(rows):
+ try:
+ v = abs(float(r[key]))
+ except (KeyError, TypeError, ValueError):
+ continue
+ if positive and v <= 0:
+ continue
+ out.append((i, v))
+ return out
+
+
def rolling(vals, w=TREND_WINDOW):
out, s = [], 0.0
for i, v in enumerate(vals):
@@ -91,6 +152,20 @@ def rolling(vals, w=TREND_WINDOW):
return out
+def bucket_means(vals, n):
+ """Split vals into <=n contiguous buckets of near-equal size; per-bucket mean."""
+ if not vals:
+ return []
+ n = min(n, len(vals))
+ k, rem = divmod(len(vals), n)
+ out, i = [], 0
+ for b in range(n):
+ size = k + (1 if b < rem else 0)
+ out.append(sum(vals[i:i + size]) / size)
+ i += size
+ return out
+
+
# ---------- tiny SVG helpers ----------
def esc(s):
@@ -113,14 +188,6 @@ def write_svg(path, text):
return True
-def svg_open(w, h, title):
- return (f'\n"
- return 1 if write_svg(out, s) else 0
-
-
-# ---------- graph 2: loss curves ----------
-
-def graph_losses(rows, out):
- if not rows:
- print("[skip] no metric rows -> no loss graph")
- return 0
-
- def col(key, log=False):
- vals = []
- for r in rows:
- try:
- v = abs(float(r[key])) # abs: log panels plot magnitude
- except (KeyError, TypeError, ValueError):
- continue
- if log and v <= 0:
- continue
- vals.append(v)
- return vals
-
- critic = col("critic_loss", log=True)
- actor = col("actor_loss", log=True)
- alpha = col("alpha")
- if not (critic or actor or alpha):
- print("[skip] metric rows lack critic_loss/actor_loss/alpha -> no loss graph")
- return 0
-
- PH, GAP = 210, 55
- H = M_T + 3 * PH + 2 * GAP + M_B + GUIDE_H
- x0, x1 = M_L, W - M_R
- n = len(rows)
- s = svg_open(W, H, "SAC-LSTM training losses (x = metric line number)")
-
- def panel(top, vals, title, log, ylab, fixed_range=None):
- nonlocal s
- y0, y1 = top + PH, top
- if not vals:
- s += (f''
- f"{esc(title)}: no valid points\n")
- return
- if log:
- lo = math.floor(math.log10(min(vals)))
- hi = math.ceil(math.log10(max(vals)))
- if lo == hi:
- hi = lo + 1
- yt = ticks_log(lo, hi, y0, y1)
- else:
- lo, hi = fixed_range or (min(vals), max(vals))
- if lo == hi:
- hi = lo + 1
- yt = ticks_linear(lo, hi, y0, y1, n=5, fmt="{:.3g}")
- s += (f''
- f"{esc(title)}\n")
- s += axis(x0, y0, x1, y1, ticks_linear(1, n, x0, x1, fmt="{:.0f}"), yt,
- "", ylab)
- ym = map_fn(y0, y1, lo, hi, log=log)
+def panel_real_fights(games, geo):
+ _, pt, pb, x0, x1 = geo
+ s = ""
+ if not games:
+ s += f'' \
+ "no game rows found\n"
+ return
+ edges = list(range(0, len(games) + 1, GAME_BUCKET))
+ if edges[-1] != len(games):
+ edges.append(len(games))
+ buckets = list(zip(edges[:-1], edges[1:]))
+ xm, ym = map_fn(x0, x1, 1, max(len(games), 2)), map_fn(pb, pt, 0, 100)
+ s += hgrid(x0, x1, [ym(v) for v in range(0, 101, 20)])
+ step = max(GAME_BUCKET, GAME_BUCKET * (len(games) // GAME_BUCKET // 8 + 1))
+ xt = [(str(v), xm(v)) for v in range(step, len(games) + 1, step)]
+ s += axis(x0, pb, x1, pt, xt, ticks_linear(0, 100, pb, pt, n=6),
+ f"game number ({GAME_BUCKET}-game buckets)", "win %")
+ opponents = []
+ for opp, _ in games:
+ if opp not in opponents:
+ opponents.append(opp)
+ items = []
+ for name in opponents:
+ c = COLORS.get(name, "#7f7f7f")
+ by_b = {}
+ for bi, (lo, hi) in enumerate(buckets):
+ sub = [w for o, w in games[lo:hi] if o == name]
+ if sub:
+ by_b[bi] = 100.0 * sum(sub) / len(sub)
pts = []
- for i, r in enumerate(rows):
- try:
- v = abs(float(r[title_key(title)]))
- except (KeyError, TypeError, ValueError):
- continue
- if log and v <= 0:
- continue
- pts.append((x0 + (x1 - x0) * i / max(n - 1, 1), ym(v)))
- s += polyline(pts, "#1f77b4", 1.6)
+ segs, prev = [], None
+ for bi in sorted(by_b):
+ if prev is not None and bi != prev + 1:
+ segs.append(pts)
+ pts = []
+ center = (buckets[bi][0] + buckets[bi][1]) / 2
+ pts.append((xm(center), ym(by_b[bi])))
+ prev = bi
+ if len(pts) >= 2:
+ segs.append(pts)
+ for seg in segs:
+ s += polyline(seg, c, 3.5)
+ n_played = sum(1 for o, _ in games if o == name)
+ items.append((c, f"{name} ({n_played} games)"))
+ s += legend(items, x0 + 12, pb + 52)
+ return s
- def title_key(title):
- return {"critic_loss (log scale)": "critic_loss",
- "actor_loss |abs| (log scale)": "actor_loss",
- "alpha (temperature)": "alpha"}[title]
+def panel_losses(rows, geo):
+ _, pt, pb, x0, x1 = geo
+ s = ""
+ critic = metric_col(rows, "critic_loss", positive=True)
+ actor = metric_col(rows, "actor_loss") # abs() applied; sign dropped
+ actor = [(i, v) for i, v in actor if v > 0]
+ if not (critic or actor):
+ s += f'' \
+ "no valid loss points\n"
+ return
+ allv = [v for _, v in critic + actor]
+ lo, hi = math.floor(math.log10(min(allv))), math.ceil(math.log10(max(allv)))
+ if lo == hi:
+ hi = lo + 1
+ n = len(rows)
+ xm = lambda i: x0 + (x1 - x0) * i / max(n - 1, 1)
+ ym = map_fn(pb, pt, lo, hi, log=True)
+ s += hgrid(x0, x1, [ym(10 ** e) for e in range(lo, hi + 1)])
+ s += axis(x0, pb, x1, pt, ticks_linear(1, n, x0, x1, n=5),
+ ticks_log(lo, hi, pb, pt), "metric line number", "loss (log)")
+ s += polyline([(xm(i), ym(v)) for i, v in critic], CRITIC_C, 1.8)
+ s += polyline([(xm(i), ym(v)) for i, v in actor], ACTOR_C, 1.8)
+ s += legend([(CRITIC_C, "critic_loss"), (ACTOR_C, "|actor_loss|")],
+ x0 + 12, pb + 52)
+ return s
- panel(M_T, critic, "critic_loss (log scale)", True, "critic_loss")
- panel(M_T + PH + GAP, actor, "actor_loss |abs| (log scale)", True, "|actor_loss|")
- panel(M_T + 2 * (PH + GAP), alpha, "alpha (temperature)", False, "alpha",
- fixed_range=(0, max(1.0, max(alpha))))
- s += (f'metric line number ({n} rows)\n')
- s += guide_block(W, H, LOSSES_GUIDE)
+def panel_alpha(rows, geo):
+ _, pt, pb, x0, x1 = geo
+ s = ""
+ alpha = metric_col(rows, "alpha")
+ if not alpha:
+ s += f'' \
+ "no alpha points\n"
+ return
+ n = len(rows)
+ hi = max(1.0, max(v for _, v in alpha))
+ xm = lambda i: x0 + (x1 - x0) * i / max(n - 1, 1)
+ ym = map_fn(pb, pt, 0, hi)
+ s += hgrid(x0, x1, [ym(v) for v in
+ [hi * k / 4 for k in range(5)]])
+ s += axis(x0, pb, x1, pt, ticks_linear(1, n, x0, x1, n=5),
+ ticks_linear(0, hi, pb, pt, n=5, fmt="{:.3g}"),
+ "metric line number", "alpha")
+ s += polyline([(xm(i), ym(v)) for i, v in alpha], "#9467bd", 1.8)
+ return s
+
+def panel_throughput(rows, geo):
+ _, pt, pb, x0, x1 = geo
+ s = ""
+ eps = []
+ for r in rows:
+ try:
+ eps.append(float(r["epoch"]))
+ except (KeyError, TypeError, ValueError):
+ continue
+ rates = [] # games/hour per inter-row interval
+ for a, b in zip(eps, eps[1:]):
+ dt = b - a
+ if dt > 0:
+ rates.append(3600.0 * GAMES_PER_ROW / dt)
+ if not rates:
+ s += f'' \
+ "no usable epoch timestamps\n"
+ return
+ bm = bucket_means(rates, RATE_BUCKET)
+ xm = map_fn(x0, x1, 1, len(rates))
+ ymax = max(max(rates), max(bm)) * 1.1
+ ym = map_fn(pb, pt, 0, ymax)
+ s += hgrid(x0, x1, [ym(ymax * k / 4) for k in range(5)])
+ step = max(1, len(rates) // 10)
+ xt = [(str(v), xm(v)) for v in range(step, len(rates) + 1, step)]
+ s += axis(x0, pb, x1, pt, xt, ticks_linear(0, ymax, pb, pt, n=5, fmt="{:.0f}"),
+ f"chunk interval ({GAMES_PER_ROW}-game chunks)", "games / hour")
+ s += dots([(xm(i + 1), ym(v)) for i, v in enumerate(rates)], "#7f7f7f", r=1.6)
+ if len(bm) >= 2:
+ ctr = [xm(round((i + 0.5) * len(rates) / len(bm))) for i in range(len(bm))]
+ s += polyline(list(zip(ctr, map(ym, bm))), "#2ca02c", 3.5)
+
+
+# ---------- assembly ----------
+ return s
+
+def build_dashboard(campaign, metrics_f, games_f, out):
+ series = parse_eval_series(campaign)
+ print("[info] evals parsed: " +
+ (", ".join(f"{k}={len(v)}" for k, v in sorted(series.items())) or "(none)"))
+ rows = parse_metrics(metrics_f)
+ print(f"[info] metric rows parsed: {len(rows)}")
+ games = parse_games(games_f)
+ print(f"[info] game rows parsed: {len(games)}")
+
+ s = (f'\n"
- return 1 if write_svg(out, s) else 0
+ return write_svg(out, s)
# ---------- selftest ----------
@@ -381,33 +490,48 @@ def selftest():
"garbage line\n"
">>> [eval] win rate: 7/10 (70%) vs Crazy\n"
">>> [eval] win rate: broken\n"
- ">>> [eval] win rate: 5/10 (50%) vs Corners\n")
+ ">>> [eval] win rate: 5/10 (50%) vs Corners\n"
+ ">>> [eval] win rate: 4/10 (40%) vs Crazy\n")
ser = parse_eval_series(td / "log")
- assert ser == {"Corners": [30.0, 50.0], "Crazy": [70.0]}, ser
+ assert ser == {"Corners": [30.0, 50.0], "Crazy": [70.0, 40.0]}, ser
assert rolling([10] * 25, 20)[-1] == 10.0
assert rolling([1, 2, 3], 20) == [1.0, 1.5, 2.0]
- assert bucket_means(list(range(1287)), BUCKETS) is not None
- assert len(bucket_means(list(range(1287)), BUCKETS)) == BUCKETS
- bm = bucket_means([0, 10], BUCKETS)
+ assert len(bucket_means(list(range(1287)), RATE_BUCKET)) == RATE_BUCKET
+ bm = bucket_means([0, 10], RATE_BUCKET)
assert bm == [0.0, 10.0], bm # fewer points than buckets -> no empty buckets
(td / "m.jsonl").write_text(
- '{"critic_loss": 10, "actor_loss": -2, "alpha": 0.5}\n'
+ '{"epoch": 1000.0, "critic_loss": 10, "actor_loss": -2, "alpha": 0.5}\n'
"not json\n"
- '{"critic_loss": 100, "actor_loss": -4, "alpha": 0.25}\n')
+ '{"epoch": 1060.0, "critic_loss": 100, "actor_loss": -4, "alpha": 0.25}\n'
+ '{"epoch": 1090.0, "critic_loss": 50, "actor_loss": 3, "alpha": 0.2}\n')
rows = parse_metrics(td / "m.jsonl")
- assert len(rows) == 2 and rows[1]["critic_loss"] == 100
- ok = graph_losses(rows, td / "g2.svg") and graph_eval(ser, {"Corners": [0, 10]}, td / "g1.svg")
- assert ok and (td / "g1.svg").stat().st_size > 500
- g1 = (td / "g1.svg").read_text()
- g2 = (td / "g2.svg").read_text()
- ET.fromstring(g1) # whole doc must parse -> closing tag present
- ET.fromstring(g2)
- assert "How to read" in g1 and "How to read" in g2, "reading guide missing"
- assert 'width="1200"' in g1, "canvas must be >=1200 wide"
- assert g1.count("= 2) + 2 # trends + v1 buckets + v3 trend
- assert g1.count(" Crazy; 149%3!=0 -> loss
+ assert metric_col(rows, "actor_loss") == [(0, 2.0), (1, 4.0), (2, 3.0)]
+
+ dash = td / "dash.svg"
+ assert build_dashboard(td / "log", td / "m.jsonl", td / "g.jsonl", dash)
+ text = dash.read_text()
+ ET.fromstring(text) # whole doc must parse -> closing tag present
+ assert RELOAD_JS in text, "auto-reload script missing"
+ for t in PANEL_TITLES:
+ assert t in text, f"panel title missing: {t}"
+ assert text.count(PANEL_TITLES[0]) == 1
+ assert "How to read" in text, "reading guide missing"
+ assert 'width="1400"' in text and 'height="2000"' in text
+ # circles: 4 eval dots (panel 1) + 2 throughput rate dots (panel 5)
+ assert text.count(" 0 else ROOT / "campaign_v2_stdout.log"
metrics = Path(args[1]) if len(args) > 1 else ROOT / "training_metrics.jsonl"
- v1log = Path(args[2]) if len(args) > 2 else ROOT / "weights_v1_archive" / "campaign_stdout.log"
+ games = Path(args[2]) if len(args) > 2 else ROOT / "training_log.jsonl"
outdir = Path(args[3]) if len(args) > 3 else ROOT / "docs"
outdir.mkdir(parents=True, exist_ok=True)
- made = 0
- series_v2 = parse_eval_series(campaign)
- print(f"[info] v2 evals parsed: " +
- ", ".join(f"{k}={len(v)}" for k, v in sorted(series_v2.items())) or "(none)")
- series_v1 = parse_eval_series(v1log)
- print(f"[info] v1 evals parsed: " +
- ", ".join(f"{k}={len(v)}" for k, v in sorted(series_v1.items())) or "(none)")
try:
- made += graph_eval(series_v2, series_v1, outdir / "graph_eval_winrates.svg")
+ ok = build_dashboard(campaign, metrics, games, outdir / "campaign_dashboard.svg")
except Exception as e:
- print(f"[error] win-rate graph failed (loss graph still attempted): {e}")
-
- rows = parse_metrics(metrics)
- print(f"[info] metric rows parsed: {len(rows)}")
- try:
- made += graph_losses(rows, outdir / "graph_losses.svg")
- except Exception as e:
- print(f"[error] loss graph failed: {e}")
-
- if made:
- print(f"[done] {made} graph(s) written to {outdir}")
+ print(f"[error] dashboard build failed: {e}")
+ ok = False
+ if ok:
+ print(f"[done] dashboard written to {outdir / 'campaign_dashboard.svg'}")
else:
- print("[error] nothing plotted - check paths above")
+ print("[error] dashboard NOT updated - check paths above")
sys.exit(1)
diff --git a/SAC_LSTM_Bot/tools/watch_dashboard.sh b/SAC_LSTM_Bot/tools/watch_dashboard.sh
new file mode 100755
index 0000000..e16fd41
--- /dev/null
+++ b/SAC_LSTM_Bot/tools/watch_dashboard.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+# Keep docs/campaign_dashboard.svg fresh: regenerate every 60 s.
+# Errors go to stderr and never exit the loop silently.
+dir=$(dirname "$0")
+while :; do
+ if ! python3 "$dir/plot_progress.py"; then
+ echo "[watch_dashboard] $(date '+%F %T') regeneration failed (see error above)" >&2
+ fi
+ sleep 60
+done