vibetrack 0.1a0__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.
vibetrack/__init__.py ADDED
@@ -0,0 +1,144 @@
1
+ """vibetrack — lightweight experiment tracking.
2
+
3
+ Drop-in compatible with TensorBoard and W&B APIs::
4
+
5
+ # TensorBoard style
6
+ from vibetrack import SummaryWriter
7
+ writer = SummaryWriter("runs/exp1")
8
+ writer.add_scalar("loss", 0.5, step)
9
+
10
+ # W&B style
11
+ import vibetrack
12
+ vibetrack.init(project="my_project", name="run_1", config={"lr": 0.01})
13
+ vibetrack.log({"loss": 0.5, "acc": 0.9})
14
+ vibetrack.finish()
15
+
16
+ # Also works as a namespace alias
17
+ import vibetrack as tb
18
+ writer = tb.SummaryWriter("runs/exp1")
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import sys
24
+ from typing import Any, Dict, Optional, Union
25
+
26
+ from .writer import SummaryWriter
27
+ from .reader import ExperimentReader, RunReader
28
+ from .smoother import smooth, ema, moving_average, gaussian
29
+ from .compare import compare_scalars, compare_hparams, summary_table
30
+ from .types import Image, Audio, Video, Artifact
31
+ from .defaults import SYSTEM_METRICS_INTERVAL
32
+
33
+ __version__ = "0.1a0"
34
+ __all__ = [
35
+ # Core
36
+ "SummaryWriter",
37
+ "ExperimentReader",
38
+ "RunReader",
39
+ # W&B-style module API
40
+ "init",
41
+ "log",
42
+ "finish",
43
+ "config",
44
+ # Smoothing
45
+ "smooth",
46
+ "ema",
47
+ "moving_average",
48
+ "gaussian",
49
+ # Compare
50
+ "compare_scalars",
51
+ "compare_hparams",
52
+ "summary_table",
53
+ # Media types
54
+ "Image",
55
+ "Audio",
56
+ "Video",
57
+ "Artifact",
58
+ ]
59
+
60
+ # ── W&B-style module-level API ──────────────────────────────────
61
+
62
+ _active_writer: Optional[SummaryWriter] = None
63
+ _step: int = 0
64
+
65
+ config: Dict[str, Any] = {}
66
+
67
+
68
+ def _warn(msg: str) -> None:
69
+ print(f"vibetrack warning: {msg}", file=sys.stderr)
70
+
71
+
72
+ def init(
73
+ project: Optional[str] = None,
74
+ name: Optional[str] = None,
75
+ config: Optional[Dict[str, Any]] = None,
76
+ log_dir: Optional[str] = None,
77
+ project_folder: Optional[str] = None,
78
+ precache_secs: float = 0,
79
+ system_metrics_interval: float = SYSTEM_METRICS_INTERVAL,
80
+ rank: Optional[Union[int, str]] = None,
81
+ **kwargs: Any,
82
+ ) -> SummaryWriter:
83
+ """Initialize a new run (W&B-style).
84
+
85
+ Only rank 0 logs by default. Other ranks get a no-op writer.
86
+ Set ``rank="all"`` to force every rank to log.
87
+
88
+ ::
89
+
90
+ import vibetrack
91
+ vibetrack.init(project="cifar10", name="resnet18", config={"lr": 1e-3})
92
+ vibetrack.init(..., system_metrics_interval=10) # collect OS/GPU stats
93
+ """
94
+ global _active_writer, _step
95
+ import vibetrack as _mod
96
+
97
+ if _active_writer is not None:
98
+ try:
99
+ _active_writer.close()
100
+ except Exception as exc:
101
+ _warn(f"failed to close active writer during init: {exc}")
102
+
103
+ _active_writer = SummaryWriter(
104
+ log_dir=log_dir, project=project, name=name, config=config,
105
+ project_folder=project_folder,
106
+ precache_secs=precache_secs,
107
+ system_metrics_interval=system_metrics_interval,
108
+ rank=rank, **kwargs
109
+ )
110
+ _step = 0
111
+ if config:
112
+ _mod.config = dict(config)
113
+ return _active_writer
114
+
115
+
116
+ def log(data: Dict[str, Any], step: Optional[int] = None, **kwargs: Any) -> None:
117
+ """Log metrics for the current step (W&B-style).
118
+
119
+ ::
120
+
121
+ vibetrack.log({"loss": 0.5, "acc": 0.9})
122
+ """
123
+ global _step
124
+ if _active_writer is None:
125
+ _warn("log() called before init(); dropping data")
126
+ return
127
+ if step is not None:
128
+ _step = step
129
+ try:
130
+ _active_writer.log(data, step=_step, **kwargs)
131
+ except Exception as exc:
132
+ _warn(f"failed to log data: {exc}")
133
+ _step += 1
134
+
135
+
136
+ def finish() -> None:
137
+ """Flush and close the active writer (W&B-style)."""
138
+ global _active_writer
139
+ if _active_writer is not None:
140
+ try:
141
+ _active_writer.close()
142
+ except Exception as exc:
143
+ _warn(f"failed to close writer during finish: {exc}")
144
+ _active_writer = None
vibetrack/cli.py ADDED
@@ -0,0 +1,450 @@
1
+ """CLI entry point for central-DB and project-local vibetrack workflows."""
2
+
3
+ import argparse
4
+ import hmac
5
+ import json
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ import threading
10
+ from pathlib import Path
11
+ from typing import List, Optional, Tuple
12
+
13
+ from .db import Database
14
+
15
+ _MAX_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB
16
+ _ALLOWED_SUFFIXES = {
17
+ "image": {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"},
18
+ "audio": {".wav", ".mp3", ".ogg", ".flac", ".m4a"},
19
+ "video": {".mp4", ".webm", ".avi", ".mov", ".mkv"},
20
+ }
21
+
22
+
23
+ async def _write_upload_to_tempfile(
24
+ upload: object,
25
+ suffix: str,
26
+ max_bytes: Optional[int] = None,
27
+ chunk_size: int = 1024 * 1024,
28
+ ) -> str:
29
+ """Stream an uploaded file to disk while enforcing a hard size limit."""
30
+ if max_bytes is None:
31
+ max_bytes = _MAX_UPLOAD_BYTES
32
+ total = 0
33
+ tmp_path: Optional[str] = None
34
+ try:
35
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
36
+ tmp_path = tmp.name
37
+ while True:
38
+ chunk = await upload.read(chunk_size) # type: ignore[attr-defined]
39
+ if not chunk:
40
+ break
41
+ total += len(chunk)
42
+ if total > max_bytes:
43
+ raise ValueError("File too large")
44
+ tmp.write(chunk)
45
+ return tmp_path
46
+ except Exception:
47
+ if tmp_path and os.path.exists(tmp_path):
48
+ os.unlink(tmp_path)
49
+ raise
50
+
51
+
52
+ def _parse_listen(s: str) -> Tuple[str, int]:
53
+ """Parse ``host:port`` or bare ``port`` string."""
54
+ if ":" in s:
55
+ host, port_str = s.rsplit(":", 1)
56
+ return host, int(port_str)
57
+ return "127.0.0.1", int(s)
58
+
59
+
60
+ def _normalize_project_folder(project_folder: Optional[str]) -> Optional[str]:
61
+ if project_folder is None:
62
+ return None
63
+ path = Path(project_folder).expanduser().resolve()
64
+ if path.name == "vibetrack.db":
65
+ return str(path.parent)
66
+ return str(path)
67
+
68
+
69
+ def _create_listen_app(project_folder: Optional[str], token: Optional[str] = None):
70
+ """Build FastAPI app for the HTTP ingest server."""
71
+ from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
72
+
73
+ from .writer import SummaryWriter
74
+
75
+ app = FastAPI(title="vibetrack-ingest")
76
+ normalized_project_folder = _normalize_project_folder(project_folder)
77
+ project_root = (
78
+ Path(normalized_project_folder).resolve()
79
+ if normalized_project_folder is not None else None
80
+ )
81
+
82
+ def _new_writer(name: str) -> SummaryWriter:
83
+ if project_root is not None:
84
+ log_dir = project_root / name
85
+ return SummaryWriter(
86
+ str(log_dir),
87
+ name=name,
88
+ project_folder=str(project_root),
89
+ system_metrics_interval=0,
90
+ )
91
+ log_dir = Path.cwd() / name
92
+ return SummaryWriter(str(log_dir), name=name, system_metrics_interval=0)
93
+
94
+ async def check_auth(request: Request): # type: ignore[no-untyped-def]
95
+ if token:
96
+ auth = request.headers.get("Authorization", "")
97
+ if not hmac.compare_digest(auth, f"Bearer {token}"):
98
+ raise HTTPException(status_code=401, detail="unauthorized")
99
+
100
+ @app.post("/log")
101
+ async def log_data(request: Request, _=Depends(check_auth)) -> dict:
102
+ data = await request.json()
103
+ experiment = data.get("experiment", "default")
104
+ if "/" in experiment or "\\" in experiment or ".." in experiment:
105
+ raise HTTPException(status_code=400, detail="Invalid experiment name")
106
+ step = data.get("step", 0)
107
+ writer = _new_writer(experiment)
108
+ try:
109
+ for tag, value in data.get("scalars", {}).items():
110
+ writer.add_scalar(tag, value, step)
111
+ for tag, value in data.get("texts", {}).items():
112
+ writer.add_text(tag, value, step)
113
+ finally:
114
+ writer.close()
115
+ return {"status": "ok"}
116
+
117
+ @app.post("/media")
118
+ async def upload_media(
119
+ _=Depends(check_auth),
120
+ experiment: str = Form("default"),
121
+ tag: str = Form("upload"),
122
+ step: int = Form(0),
123
+ type: str = Form("artifact"),
124
+ file: UploadFile = File(...),
125
+ ) -> dict:
126
+ if "/" in experiment or "\\" in experiment or ".." in experiment:
127
+ raise HTTPException(status_code=400, detail="Invalid experiment name")
128
+ writer: Optional[SummaryWriter] = None
129
+ tmp_path: Optional[str] = None
130
+ try:
131
+ raw_suffix = os.path.splitext(file.filename or "")[1].lower()
132
+ if type not in {"image", "audio", "video", "artifact"}:
133
+ raise HTTPException(status_code=400, detail=f"Unsupported upload type {type!r}")
134
+
135
+ if type == "artifact":
136
+ if raw_suffix in {".html", ".htm", ".js", ".svg", ".php", ".sh", ".exe", ".cgi", ".pl"}:
137
+ raise HTTPException(status_code=400, detail="Refusing to serve potentially dangerous file extension")
138
+ else:
139
+ allowed = _ALLOWED_SUFFIXES.get(type)
140
+ if allowed is not None and raw_suffix not in allowed:
141
+ raise HTTPException(status_code=400, detail=f"Unsupported file type for {type!r}")
142
+
143
+ try:
144
+ tmp_path = await _write_upload_to_tempfile(file, suffix=raw_suffix)
145
+ except ValueError:
146
+ raise HTTPException(status_code=413, detail="File too large (max 1 GB)")
147
+
148
+ writer = _new_writer(experiment)
149
+ if type == "image":
150
+ writer.add_image(tag, tmp_path, step)
151
+ elif type == "audio":
152
+ writer.add_audio(tag, tmp_path, step)
153
+ elif type == "video":
154
+ writer.add_video(tag, tmp_path, step)
155
+ else:
156
+ writer.add_artifact(tag, tmp_path, step)
157
+ return {"status": "ok"}
158
+ finally:
159
+ if writer is not None:
160
+ writer.close()
161
+ if tmp_path and os.path.exists(tmp_path):
162
+ os.unlink(tmp_path)
163
+ await file.close()
164
+
165
+ return app
166
+
167
+
168
+ def _start_listen_server(
169
+ project_folder: Optional[str],
170
+ host: str,
171
+ port: int,
172
+ token: Optional[str],
173
+ ) -> threading.Thread:
174
+ """Start the ingest server in a daemon thread."""
175
+ import uvicorn
176
+
177
+ app = _create_listen_app(project_folder, token)
178
+ config = uvicorn.Config(app, host=host, port=port, log_level="warning")
179
+ server = uvicorn.Server(config)
180
+ thread = threading.Thread(target=server.run, daemon=True)
181
+ thread.start()
182
+ print(f"vibetrack listen server: http://{host}:{port}")
183
+ return thread
184
+
185
+
186
+ def _experiment_has_data(db: Database, experiment_id: int) -> bool:
187
+ return any([
188
+ db.get_scalar_tags(experiment_id),
189
+ db.get_text_tags(experiment_id),
190
+ db.get_image_tags(experiment_id),
191
+ db.get_audio_tags(experiment_id),
192
+ db.get_video_tags(experiment_id),
193
+ db.get_artifact_tags(experiment_id),
194
+ db.get_histogram_tags(experiment_id),
195
+ db.get_hparams(experiment_id),
196
+ ])
197
+
198
+
199
+ def _migrate_experiment(
200
+ source_db: Database,
201
+ source_row: object,
202
+ target_db: Database,
203
+ target_project: str,
204
+ fallback_log_dir: str,
205
+ ) -> bool:
206
+ row = dict(source_row)
207
+ name = row["name"]
208
+ source_id = row["id"]
209
+ config = json.loads(row["config"]) if row.get("config") else None
210
+ log_dir = row.get("log_dir") or fallback_log_dir
211
+
212
+ existing = target_db.get_experiment_by_name(name, project=target_project)
213
+ if existing is not None and _experiment_has_data(target_db, existing["id"]):
214
+ return False
215
+
216
+ target_id = target_db.create_experiment(
217
+ name,
218
+ config=config,
219
+ project=target_project,
220
+ log_dir=log_dir,
221
+ )
222
+
223
+ scalar_rows = []
224
+ for tag in source_db.get_scalar_tags(source_id):
225
+ scalar_rows.extend(
226
+ (target_id, tag, row["step"], row["value"], row["wall_time"])
227
+ for row in source_db.get_scalars(source_id, tag)
228
+ )
229
+ if scalar_rows:
230
+ target_db.add_scalars_bulk(scalar_rows)
231
+
232
+ for hparams in [source_db.get_hparams(source_id)]:
233
+ if hparams:
234
+ target_db.add_hparams(target_id, hparams)
235
+
236
+ for tag in source_db.get_text_tags(source_id):
237
+ for entry in source_db.get_texts(source_id, tag):
238
+ target_db.add_text(
239
+ target_id,
240
+ tag,
241
+ entry["value"],
242
+ entry["step"],
243
+ entry["wall_time"],
244
+ )
245
+
246
+ for tag in source_db.get_image_tags(source_id):
247
+ for entry in source_db.get_images(source_id, tag):
248
+ target_db.add_image(
249
+ target_id,
250
+ tag,
251
+ entry["path"],
252
+ entry["step"],
253
+ entry["wall_time"],
254
+ )
255
+
256
+ for tag in source_db.get_audio_tags(source_id):
257
+ for entry in source_db.get_audio(source_id, tag):
258
+ target_db.add_audio(
259
+ target_id,
260
+ tag,
261
+ entry["path"],
262
+ entry["step"],
263
+ entry["sample_rate"],
264
+ entry["wall_time"],
265
+ )
266
+
267
+ for tag in source_db.get_video_tags(source_id):
268
+ for entry in source_db.get_video(source_id, tag):
269
+ target_db.add_video(
270
+ target_id,
271
+ tag,
272
+ entry["path"],
273
+ entry["step"],
274
+ entry["wall_time"],
275
+ )
276
+
277
+ for tag in source_db.get_artifact_tags(source_id):
278
+ for entry in source_db.get_artifacts(source_id, tag):
279
+ target_db.add_artifact(
280
+ target_id,
281
+ tag,
282
+ entry["path"],
283
+ entry["metadata"],
284
+ entry["step"],
285
+ entry["wall_time"],
286
+ )
287
+
288
+ for tag in source_db.get_histogram_tags(source_id):
289
+ for entry in source_db.get_histograms(source_id, tag):
290
+ target_db.add_histogram(
291
+ target_id,
292
+ tag,
293
+ entry["bins"],
294
+ entry["counts"],
295
+ entry["step"],
296
+ entry["wall_time"],
297
+ )
298
+
299
+ return True
300
+
301
+
302
+ def migrate_project(project_folder: str) -> int:
303
+ """Merge legacy per-run DBs into the project-level DB."""
304
+ project_root = Path(project_folder).resolve()
305
+ if not project_root.exists():
306
+ print(f"Error: project folder {str(project_root)!r} does not exist.", file=sys.stderr)
307
+ return 1
308
+
309
+ target_db_path = project_root / "vibetrack.db"
310
+ legacy_dbs = sorted(
311
+ path for path in project_root.rglob("vibetrack.db")
312
+ if path != target_db_path
313
+ )
314
+ if not legacy_dbs:
315
+ print(f"No legacy run databases found under {project_root}.")
316
+ return 0
317
+
318
+ target_project = project_root.name
319
+ target_db = Database(target_db_path)
320
+ migrated = 0
321
+ skipped = 0
322
+ try:
323
+ for db_path in legacy_dbs:
324
+ source_db = Database(db_path)
325
+ try:
326
+ fallback_log_dir = str(db_path.parent.resolve())
327
+ for row in source_db.list_experiments():
328
+ if _migrate_experiment(
329
+ source_db,
330
+ row,
331
+ target_db,
332
+ target_project,
333
+ fallback_log_dir,
334
+ ):
335
+ migrated += 1
336
+ else:
337
+ skipped += 1
338
+ finally:
339
+ source_db.close()
340
+ finally:
341
+ target_db.close()
342
+
343
+ print(
344
+ f"Migrated {migrated} experiment(s) into {target_db_path} "
345
+ f"from {len(legacy_dbs)} legacy DB(s); skipped {skipped} existing experiment(s)."
346
+ )
347
+ return 0
348
+
349
+
350
+ def _build_parser() -> argparse.ArgumentParser:
351
+ parser = argparse.ArgumentParser(
352
+ prog="vibetrack",
353
+ description="Lightweight experiment tracking.",
354
+ )
355
+ parser.add_argument(
356
+ "target_pos",
357
+ nargs="?",
358
+ default=None,
359
+ metavar="PROJECT_FOLDER",
360
+ help="Project folder, or the literal command 'migrate'.",
361
+ )
362
+ parser.add_argument(
363
+ "project_folder_pos",
364
+ nargs="?",
365
+ default=None,
366
+ metavar="PROJECT_FOLDER",
367
+ help=argparse.SUPPRESS,
368
+ )
369
+ parser.add_argument(
370
+ "--project-folder",
371
+ default=None,
372
+ help="Project folder containing a local vibetrack.db",
373
+ )
374
+ parser.add_argument(
375
+ "--logdir",
376
+ dest="legacy_project_folder",
377
+ default=None,
378
+ help=argparse.SUPPRESS,
379
+ )
380
+ parser.add_argument(
381
+ "--viewer",
382
+ default="web",
383
+ help="Viewer backend (default: web). Web includes MCP + ingest. Use 'mcp', 'console', etc. for standalone.",
384
+ )
385
+ parser.add_argument(
386
+ "--listen",
387
+ metavar="HOST:PORT",
388
+ help="Start HTTP ingest server on host:port to receive remote logs",
389
+ )
390
+ parser.add_argument(
391
+ "--token",
392
+ help="Bearer token for listen server authentication",
393
+ )
394
+ parser.add_argument(
395
+ "--host",
396
+ default="0.0.0.0",
397
+ help="Viewer host (default: 0.0.0.0)",
398
+ )
399
+ parser.add_argument(
400
+ "--port",
401
+ type=int,
402
+ default=6006,
403
+ help="Viewer port (default: 6006)",
404
+ )
405
+ parser.add_argument(
406
+ "--mcp-transport",
407
+ default="streamable-http",
408
+ choices=["streamable-http", "sse"],
409
+ help="MCP transport type (default: streamable-http). Only used with --viewer=mcp.",
410
+ )
411
+ return parser
412
+
413
+
414
+ def main(argv: Optional[List[str]] = None) -> None:
415
+ parser = _build_parser()
416
+ args = parser.parse_args(argv)
417
+
418
+ if args.legacy_project_folder is not None and args.target_pos is None:
419
+ args.project_folder = args.legacy_project_folder
420
+
421
+ if args.target_pos == "migrate":
422
+ project_folder = args.project_folder_pos or args.project_folder or "."
423
+ sys.exit(migrate_project(project_folder))
424
+
425
+ if args.target_pos is not None:
426
+ args.project_folder = args.target_pos
427
+
428
+ project_folder = _normalize_project_folder(args.project_folder)
429
+
430
+ if args.listen:
431
+ try:
432
+ listen_host, listen_port = _parse_listen(args.listen)
433
+ except ValueError:
434
+ print(f"Invalid --listen value: {args.listen!r}. Use HOST:PORT or PORT.", file=sys.stderr)
435
+ sys.exit(1)
436
+ try:
437
+ _start_listen_server(project_folder, listen_host, listen_port, args.token)
438
+ except ImportError:
439
+ print("FastAPI/uvicorn required for --listen. Install with: pip install vibetrack[web]", file=sys.stderr)
440
+ sys.exit(1)
441
+
442
+ from .viewers import load_viewer
443
+
444
+ viewer_cls = load_viewer(args.viewer)
445
+ viewer = viewer_cls(project_folder)
446
+ viewer.show(host=args.host, port=args.port, token=args.token, mcp_transport=args.mcp_transport)
447
+
448
+
449
+ if __name__ == "__main__":
450
+ main()
vibetrack/compare.py ADDED
@@ -0,0 +1,109 @@
1
+ """Compare metrics across multiple experiments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional, Sequence
6
+
7
+ from .reader import ExperimentReader, RunReader
8
+ from .smoother import smooth
9
+
10
+
11
+ def compare_scalars(
12
+ experiments: Sequence[ExperimentReader],
13
+ tag: str,
14
+ smoothing: str = "none",
15
+ **smooth_kwargs: float,
16
+ ) -> List[Dict[str, Any]]:
17
+ """Get scalar data for a tag across multiple experiments.
18
+
19
+ Returns a list of dicts, one per experiment::
20
+
21
+ [
22
+ {
23
+ "name": "run_001",
24
+ "steps": [0, 1, 2, ...],
25
+ "values": [0.9, 0.7, 0.5, ...],
26
+ "smoothed": [0.9, 0.8, 0.65, ...], # if smoothing != "none"
27
+ },
28
+ ...
29
+ ]
30
+ """
31
+ results: List[Dict[str, Any]] = []
32
+ for exp in experiments:
33
+ data = exp.scalars(tag)
34
+ if not data:
35
+ continue
36
+ steps = [d["step"] for d in data]
37
+ values = [d["value"] for d in data]
38
+ entry: Dict[str, Any] = {
39
+ "name": exp.name,
40
+ "experiment_id": exp.experiment_id,
41
+ "steps": steps,
42
+ "values": values,
43
+ }
44
+ if smoothing != "none":
45
+ entry["smoothed"] = smooth(values, method=smoothing, **smooth_kwargs)
46
+ results.append(entry)
47
+ return results
48
+
49
+
50
+ def compare_hparams(
51
+ experiments: Sequence[ExperimentReader],
52
+ ) -> List[Dict[str, Any]]:
53
+ """Get hyperparameters for multiple experiments side-by-side.
54
+
55
+ Returns::
56
+
57
+ [
58
+ {"name": "run_001", "hparams": {"lr": 0.01, "batch_size": 32}},
59
+ ...
60
+ ]
61
+ """
62
+ return [
63
+ {"name": exp.name, "experiment_id": exp.experiment_id, "hparams": exp.hparams()}
64
+ for exp in experiments
65
+ ]
66
+
67
+
68
+ def find_common_tags(experiments: Sequence[ExperimentReader]) -> List[str]:
69
+ """Return scalar tags that exist in all experiments."""
70
+ if not experiments:
71
+ return []
72
+ tag_sets = [set(exp.scalar_tags()) for exp in experiments]
73
+ common = tag_sets[0]
74
+ for ts in tag_sets[1:]:
75
+ common &= ts
76
+ return sorted(common)
77
+
78
+
79
+ def find_all_tags(experiments: Sequence[ExperimentReader]) -> List[str]:
80
+ """Return the union of all scalar tags across experiments."""
81
+ tags: set[str] = set()
82
+ for exp in experiments:
83
+ tags.update(exp.scalar_tags())
84
+ return sorted(tags)
85
+
86
+
87
+ def summary_table(
88
+ experiments: Sequence[ExperimentReader],
89
+ tags: Optional[Sequence[str]] = None,
90
+ ) -> List[Dict[str, Any]]:
91
+ """Build a summary table: last value of each tag per experiment.
92
+
93
+ Returns::
94
+
95
+ [
96
+ {"name": "run_001", "loss": 0.12, "acc": 0.95, ...},
97
+ ...
98
+ ]
99
+ """
100
+ if tags is None:
101
+ tags = find_all_tags(experiments)
102
+ rows: List[Dict[str, Any]] = []
103
+ for exp in experiments:
104
+ row: Dict[str, Any] = {"name": exp.name, "experiment_id": exp.experiment_id}
105
+ for tag in tags:
106
+ data = exp.scalars(tag)
107
+ row[tag] = data[-1]["value"] if data else None
108
+ rows.append(row)
109
+ return rows