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 +144 -0
- vibetrack/cli.py +450 -0
- vibetrack/compare.py +109 -0
- vibetrack/config.py +120 -0
- vibetrack/db.py +1268 -0
- vibetrack/defaults.py +39 -0
- vibetrack/media.py +237 -0
- vibetrack/reader.py +248 -0
- vibetrack/smoother.py +111 -0
- vibetrack/sysmetrics.py +562 -0
- vibetrack/types.py +81 -0
- vibetrack/viewers/__init__.py +67 -0
- vibetrack/viewers/base.py +53 -0
- vibetrack/viewers/console.py +122 -0
- vibetrack/viewers/gradio_ui.py +131 -0
- vibetrack/viewers/mcp.py +284 -0
- vibetrack/viewers/telegram.py +114 -0
- vibetrack/viewers/web.html +2538 -0
- vibetrack/viewers/web.py +623 -0
- vibetrack/writer.py +497 -0
- vibetrack-0.1a0.dist-info/METADATA +479 -0
- vibetrack-0.1a0.dist-info/RECORD +26 -0
- vibetrack-0.1a0.dist-info/WHEEL +5 -0
- vibetrack-0.1a0.dist-info/entry_points.txt +2 -0
- vibetrack-0.1a0.dist-info/licenses/LICENSE +183 -0
- vibetrack-0.1a0.dist-info/top_level.txt +1 -0
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
|