diff --git a/.gitignore b/.gitignore index 2dc4b73..8ba5cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ data/graph_library/assets.json /docs/locales/**/*.mo .diff-worktrees/ data/runtime/ +data/runs/ diff --git a/comfy_research/api/runs.py b/comfy_research/api/runs.py new file mode 100644 index 0000000..9fe2859 --- /dev/null +++ b/comfy_research/api/runs.py @@ -0,0 +1,176 @@ +# comfy_research/api/runs.py +"""Run store API: async submit, query, metrics, abort, delete. Files are truth.""" +from __future__ import annotations + +import shutil + +from fastapi import APIRouter, Header, HTTPException, Request + +from comfy_research.engine.runs import run_index, run_store +from comfy_research.engine.runs.run_index import RunQuery +from comfy_research.engine.runs.run_worker import get_worker_pool +from comfy_research.schemas.run_record import TERMINAL_STATUSES +from comfy_research.schemas.train_request import TrainRequest + +router = APIRouter(prefix="/api/runs", tags=["runs"]) + +_HYPERPARAM_PREFIX = "hyperparam." + + +def _err(status: int, code: str, detail: str) -> HTTPException: + return HTTPException(status_code=status, detail={"code": code, "detail": detail}) + + +def _validate_run_id(run_id: str) -> None: + """Reject a malformed ``run_id`` path param (e.g. path-traversal-shaped) with a + structured 400 instead of letting ``run_store.run_dir``'s ``ValueError`` surface + as an unstructured 500 from deeper in the call stack.""" + try: + run_store.run_dir(run_id) + except ValueError: + raise _err(400, "invalid_run_id", f"{run_id!r} is not a valid run ID.") + + +def _parse_float_param(p, name: str) -> float | None: + raw = p.get(name) + if not raw: + return None + try: + return float(raw) + except ValueError: + raise _err(400, "invalid_query_param", f"{name}={raw!r} is not a valid number.") + + +def _parse_int_param(p, name: str, default: int) -> int: + raw = p.get(name) + if not raw: + return default + try: + return int(raw) + except ValueError: + raise _err(400, "invalid_query_param", f"{name}={raw!r} is not a valid integer.") + + +def _query_from_request(request: Request) -> RunQuery: + p = request.query_params + hyper = {k[len(_HYPERPARAM_PREFIX):]: v for k, v in p.items() + if k.startswith(_HYPERPARAM_PREFIX)} + ids = [s for s in (p.get("ids") or "").split(",") if s] or None + return RunQuery( + status=p.get("status"), origin=p.get("origin"), group_id=p.get("group_id"), + since_ms=_parse_float_param(p, "since"), + ids=ids, hyperparams=hyper, + order_by=p.get("order_by") or "-created_at", + limit=_parse_int_param(p, "limit", 100), cursor=p.get("cursor"), + ) + + +@router.post("", status_code=202) +def submit_run(body: TrainRequest, + idempotency_key: str | None = Header(default=None)) -> dict: + rec = get_worker_pool().submit(body, idempotency_key=idempotency_key) + return {"run_id": rec.run_id, "status": rec.status} + + +@router.get("") +def list_runs(request: Request) -> dict: + q = _query_from_request(request) + try: + rows, next_cursor = run_index.query_runs(q) + except (ValueError, IndexError): + raise _err(400, "invalid_cursor", f"cursor {q.cursor!r} could not be parsed.") + return {"runs": rows, "next_cursor": next_cursor} + + +@router.get("/groups") +def list_groups() -> dict: + return {"groups": run_index.group_summary()} + + +@router.get("/{run_id}") +def get_run(run_id: str) -> dict: + _validate_run_id(run_id) + rec = run_store.read_run_record(run_id) + if rec is None: + if (run_store.run_dir(run_id) / "run.json").exists(): + # file present but unparseable: surface it, don't 404 (spec: unreadable) + return {"run_id": run_id, "status": "unreadable", "summary": None} + raise _err(404, "run_not_found", f"No run {run_id!r}.") + _, series = run_store.load_series(run_id) + return {**rec.model_dump(mode="json"), "summary": run_store.summarize(series)} + + +@router.get("/{run_id}/metrics") +def get_run_metrics(run_id: str, downsample: int | None = None) -> dict: + _validate_run_id(run_id) + if run_store.read_run_record(run_id) is None: + raise _err(404, "run_not_found", f"No run {run_id!r}.") + source, data = run_store.load_series(run_id) + downsampled = False + if downsample and downsample > 0: + out = {} + for key, series in data.items(): + if isinstance(series, list) and len(series) > downsample: + stride = -(-len(series) // downsample) + out[key] = series[::stride] + downsampled = True + else: + out[key] = series + data = out + return {"source": source, "data": data, "downsampled": downsampled} + + +@router.post("/{run_id}/abort") +def abort_run(run_id: str) -> dict: + _validate_run_id(run_id) + ok = get_worker_pool().abort(run_id) + if not ok: + rec = run_store.read_run_record(run_id) + if rec is None: + raise _err(404, "run_not_found", f"No run {run_id!r}.") + return {"ok": False} + return {"ok": True} + + +def _delete_run_files(run_id: str) -> None: + shutil.rmtree(run_store.run_dir(run_id), ignore_errors=True) + run_index.delete_rows([run_id]) + + +_DELETABLE_STATUSES = TERMINAL_STATUSES | {"unreadable"} + + +@router.delete("/{run_id}") +def delete_run(run_id: str) -> dict: + _validate_run_id(run_id) + rec = run_store.read_run_record(run_id) + if rec is None: + if (run_store.run_dir(run_id) / "run.json").exists(): + _delete_run_files(run_id) # unreadable: deletable, that's the point + return {"ok": True} + raise _err(404, "run_not_found", f"No run {run_id!r}.") + if rec.status not in _DELETABLE_STATUSES: + raise _err(409, "run_active", "Abort the run before deleting it.") + _delete_run_files(run_id) + return {"ok": True} + + +@router.delete("") +def bulk_delete(request: Request) -> dict: + q = _query_from_request(request) + if not any([q.status, q.origin, q.group_id, q.since_ms, q.ids, q.hyperparams]): + raise _err(400, "filter_required", + "Bulk delete requires at least one filter (status/origin/group_id/since/ids).") + # Collect ALL matching ids first (paginate to the end), then delete the deletable + # ones — deleting while paginating would shift the cursor and skip matches. + q.limit = 500 + to_delete: list[str] = [] + while True: + rows, cursor = run_index.query_runs(q) + to_delete.extend(r["run_id"] for r in rows if r["status"] in _DELETABLE_STATUSES) + if cursor is None: + break + q.cursor = cursor + for run_id in to_delete: + _delete_run_files(run_id) + return {"deleted": len(to_delete)} diff --git a/comfy_research/api/train.py b/comfy_research/api/train.py index edc556d..2560f25 100644 --- a/comfy_research/api/train.py +++ b/comfy_research/api/train.py @@ -1,12 +1,15 @@ from __future__ import annotations import json +import logging from dataclasses import asdict from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field +logger = logging.getLogger(__name__) + from comfy_research.engine.runs.sweep_control import request_sweep_abort from comfy_research.engine.runs.train_coordinate_descent import ( TrainCoordinateDescentRequest, @@ -19,6 +22,7 @@ from comfy_research.engine.crl.crl_run import iter_crl_events_from_context, prepare_crl_run from comfy_research.engine.runs.cuda_devices import list_local_cuda_devices from comfy_research.engine.runs.trainer_run import iter_trainer_events_from_context, prepare_trainer_run +from comfy_research.engine.runs.run_writer import RunWriter, build_run_record, capture_events from comfy_research.schemas.graph import NodeKind from comfy_research.config.remote_train_config import ( RemoteTrainConfig, @@ -412,7 +416,28 @@ def generate_remote(): set_last_validation_result(False, str(exc)) yield _ndjson_encode({"type": "error", "detail": str(exc)}) return - yield from iter_remote_train_stdout_lines(body, config=cfg_holder["cfg"]) + writer = RunWriter(build_run_record(body)) + yield _ndjson_encode({"type": "run_registered", "run_id": writer.record.run_id}) + writer.mark_running() + logged_capture_error = False + try: + for raw in iter_remote_train_stdout_lines(body, config=cfg_holder["cfg"]): + try: + writer.on_event(json.loads(raw.decode("utf-8"))) + except Exception: + # Unparseable remote line: forward it to the client but don't + # capture it into the run store. Log once per stream (not per + # line) so a chatty remote process can't spam the log. + if not logged_capture_error: + logger.warning( + "run %s: dropping unparseable remote NDJSON line(s) " + "from capture (forwarding to client unchanged)", + writer.record.run_id, exc_info=True, + ) + logged_capture_error = True + yield raw + finally: + writer.finalize_disconnect() return StreamingResponse( generate_remote(), @@ -452,8 +477,11 @@ def generate_crl(): hessian_oversized_policy=body.hessian_oversized_policy, ) + writer = RunWriter(build_run_record(body)) + def generate(): - for event in iter_trainer_events_from_context(ctx): + yield _ndjson_encode({"type": "run_registered", "run_id": writer.record.run_id}) + for event in capture_events(iter_trainer_events_from_context(ctx), writer): yield _ndjson_encode(event) return StreamingResponse( diff --git a/comfy_research/engine/runs/run_gc.py b/comfy_research/engine/runs/run_gc.py new file mode 100644 index 0000000..7ec0494 --- /dev/null +++ b/comfy_research/engine/runs/run_gc.py @@ -0,0 +1,69 @@ +"""Single-owner retention GC for agent- and sweep-origin runs. Runs only in the API server process.""" +from __future__ import annotations + +import json +import logging +import shutil + +from comfy_research.engine.runs import run_index, run_store +from comfy_research.engine.runs.run_index import RunQuery +from comfy_research.schemas.run_record import TERMINAL_STATUSES, now_ms + +logger = logging.getLogger(__name__) + +_GRACE_MS = 10 * 60 * 1000.0 +_DEFAULTS = { + "max_runs_agent": 2000, "max_age_days_agent": None, + "max_runs_sweep": 2000, + "worker_slots": 2, +} +# (origin, cap-config-key): sweep is the highest-volume producer (one run per +# evaluated point in a sweep or coordinate-descent session), so it gets the same +# cap/grace/terminal treatment as agent-origin runs. +_CAPPED_ORIGINS = (("agent", "max_runs_agent"), ("sweep", "max_runs_sweep")) + + +def load_gc_config() -> dict: + path = run_store.runs_root() / "config.json" + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + raw = {} + return {**_DEFAULTS, **{k: raw[k] for k in _DEFAULTS if k in raw}} + + +def run_gc_once() -> list[str]: + cfg = load_gc_config() + cutoff_ms = now_ms() - _GRACE_MS + max_age = cfg["max_age_days_agent"] + age_cutoff = now_ms() - max_age * 86_400_000.0 if max_age else None + + def prunable(r: dict) -> bool: + return (r["status"] in TERMINAL_STATUSES + and r["finished_at"] is not None and r["finished_at"] < cutoff_ms) + + pruned: list[str] = [] + for origin, cap_key in _CAPPED_ORIGINS: + rows: list[dict] = [] + q = RunQuery(origin=origin, order_by="-created_at", limit=500) + while True: + page, cursor = run_index.query_runs(q) + rows.extend(page) + if cursor is None: + break + q.cursor = cursor + cap = cfg[cap_key] + over_cap = rows[cap:] # rows are newest-first + for r in over_cap: + if prunable(r): + pruned.append(r["run_id"]) + if origin == "agent" and age_cutoff is not None: + for r in rows[:cap]: + if prunable(r) and r["finished_at"] < age_cutoff: + pruned.append(r["run_id"]) + for run_id in pruned: + shutil.rmtree(run_store.run_dir(run_id), ignore_errors=True) + run_index.delete_rows(pruned) + for run_id in pruned: + logger.info("run GC pruned %s", run_id) + return pruned diff --git a/comfy_research/engine/runs/run_index.py b/comfy_research/engine/runs/run_index.py new file mode 100644 index 0000000..929d8b7 --- /dev/null +++ b/comfy_research/engine/runs/run_index.py @@ -0,0 +1,272 @@ +"""Rebuildable SQLite read-index over ``data/runs/*/run.json`` (never a truth source).""" +from __future__ import annotations + +import json +import logging +import sqlite3 +from dataclasses import dataclass, field +from pathlib import Path + +from comfy_research.engine.runs import run_store +from comfy_research.schemas.run_record import RunRecord, now_ms + +logger = logging.getLogger(__name__) + +_COLUMNS = ( + "run_id", "group_id", "parent_id", "origin", "status", "created_at", + "started_at", "finished_at", "last_heartbeat_at", "trainer_node_id", + "device", "trainer_title", "error_detail", "hyperparams_json", "final_loss", + "final_test_loss", "best_test_loss", "steps_completed", "duration_seconds", +) +_ORDERABLE = {"created_at", "finished_at", "final_loss", "final_test_loss", + "best_test_loss", "steps_completed", "duration_seconds"} + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY, group_id TEXT, parent_id TEXT, + origin TEXT NOT NULL, status TEXT NOT NULL, + created_at REAL NOT NULL, started_at REAL, finished_at REAL, + last_heartbeat_at REAL, trainer_node_id TEXT NOT NULL, + device TEXT DEFAULT '', trainer_title TEXT DEFAULT '', error_detail TEXT DEFAULT '', + hyperparams_json TEXT NOT NULL DEFAULT '{}', + final_loss REAL, final_test_loss REAL, best_test_loss REAL, + steps_completed INTEGER, duration_seconds REAL +); +CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status); +CREATE INDEX IF NOT EXISTS idx_runs_group ON runs(group_id); +CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at); +""" + + +def index_path() -> Path: + return run_store.runs_root() / "index.db" + + +def _connect() -> sqlite3.Connection: + index_path().parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(index_path(), timeout=5) + conn.execute("PRAGMA journal_mode=WAL") + conn.executescript(_SCHEMA) + cols = {row[1] for row in conn.execute("PRAGMA table_info(runs)")} + if "trainer_title" not in cols: + try: + conn.execute("ALTER TABLE runs ADD COLUMN trainer_title TEXT DEFAULT ''") + except sqlite3.OperationalError: + pass + return conn + + +def upsert_run(rec: RunRecord, summary: dict | None = None) -> None: + s = summary or {} + duration = ( + (rec.finished_at - rec.started_at) / 1000.0 + if rec.finished_at is not None and rec.started_at is not None else None + ) + try: + with _connect() as conn: + conn.execute( + f"INSERT OR REPLACE INTO runs ({','.join(_COLUMNS)}) " + f"VALUES ({','.join('?' * len(_COLUMNS))})", + (rec.run_id, rec.group_id, rec.parent_id, rec.origin, rec.status, + rec.created_at, rec.started_at, rec.finished_at, now_ms(), + rec.trainer_node_id, rec.device, rec.trainer_title, rec.error_detail, + json.dumps(rec.hyperparams), s.get("final_loss"), + s.get("final_test_loss"), s.get("best_test_loss"), + s.get("steps_completed"), duration), + ) + except sqlite3.Error: + logger.warning("run index upsert failed for %s", rec.run_id, exc_info=True) + + +def touch_heartbeat(run_id: str, at_ms: float) -> None: + try: + with _connect() as conn: + conn.execute("UPDATE runs SET last_heartbeat_at=? WHERE run_id=?", (at_ms, run_id)) + except sqlite3.Error: + logger.warning("run index heartbeat failed for %s", run_id, exc_info=True) + + +@dataclass +class RunQuery: + status: str | None = None + origin: str | None = None + group_id: str | None = None + since_ms: float | None = None + ids: list[str] | None = None + hyperparams: dict[str, str] = field(default_factory=dict) + order_by: str = "-created_at" + limit: int = 100 + cursor: str | None = None + + +def _row_to_dict(row: tuple) -> dict: + d = dict(zip(_COLUMNS, row)) + d["hyperparams"] = json.loads(d.pop("hyperparams_json") or "{}") + return d + + +def query_runs(q: RunQuery) -> tuple[list[dict], str | None]: + key = q.order_by.lstrip("-") + if key not in _ORDERABLE: + key, q = "created_at", RunQuery(**{**q.__dict__, "order_by": "-created_at"}) + direction = "DESC" if q.order_by.startswith("-") else "ASC" + where, params = ["1=1"], [] + for col, val in (("status", q.status), ("origin", q.origin), ("group_id", q.group_id)): + if val is not None: + where.append(f"{col}=?") + params.append(val) + if q.since_ms is not None: + where.append("created_at>=?") + params.append(q.since_ms) + if q.ids: + where.append(f"run_id IN ({','.join('?' * len(q.ids))})") + params.extend(q.ids) + for hk, hv in q.hyperparams.items(): + where.append("CAST(json_extract(hyperparams_json, ?) AS TEXT)=?") + params.extend([f'$."{hk}"', hv]) + + # Handle cursor pagination with NULL-safe keyset ordering + if q.cursor: + if q.cursor.startswith("n:"): # NULL key cursor + cid = q.cursor[2:] + op = "<" if direction == "DESC" else ">" + where.append(f"{key} IS NULL AND run_id {op} ?") + params.append(cid) + else: # Non-NULL key cursor (format "v::") + parts = q.cursor.rsplit(":", 1) + cv_str = parts[0][2:] # Remove "v:" prefix + cid = parts[1] + cv = float(cv_str) + op = "<" if direction == "DESC" else ">" + where.append(f"({key} {op} ? OR ({key} = ? AND run_id {op} ?) OR {key} IS NULL)") + params.extend([cv, cv, cid]) + + # Portable NULL ordering: ({key} IS NULL) ASC sorts 0 for non-NULL, 1 for NULL (NULLs last) + sql = (f"SELECT {','.join(_COLUMNS)} FROM runs WHERE {' AND '.join(where)} " + f"ORDER BY ({key} IS NULL) ASC, {key} {direction}, run_id {direction} LIMIT ?") + limit = max(1, min(int(q.limit), 500)) + with _connect() as conn: + rows = [_row_to_dict(r) for r in conn.execute(sql, [*params, limit + 1])] + + next_cursor = None + if len(rows) > limit: + rows = rows[:limit] + last = rows[-1] + # Encode cursor: "n:" for NULL key, "v::" for non-NULL + if last[key] is None: + next_cursor = f"n:{last['run_id']}" + else: + next_cursor = f"v:{last[key]}:{last['run_id']}" + return rows, next_cursor + + +def group_summary() -> list[dict]: + with _connect() as conn: + raw = conn.execute( + "SELECT group_id, status, COUNT(*), MIN(best_test_loss), MIN(final_loss) " + "FROM runs WHERE group_id IS NOT NULL GROUP BY group_id, status" + ).fetchall() + groups: dict[str, dict] = {} + for gid, status, count, best_test, best_final in raw: + g = groups.setdefault(gid, {"group_id": gid, "counts": {}, + "best_test_loss": None, "best_final_loss": None}) + g["counts"][status] = count + for k, v in (("best_test_loss", best_test), ("best_final_loss", best_final)): + if v is not None and (g[k] is None or v < g[k]): + g[k] = v + return sorted(groups.values(), key=lambda g: g["group_id"]) + + +def delete_rows(run_ids: list[str]) -> None: + if not run_ids: + return + with _connect() as conn: + conn.execute(f"DELETE FROM runs WHERE run_id IN ({','.join('?' * len(run_ids))})", run_ids) + + +def rebuild_index() -> int: + root = run_store.runs_root() + count = 0 + if index_path().exists(): + index_path().unlink() + for entry in sorted(root.glob("run-*/run.json")): + run_id = entry.parent.name + rec = run_store.read_run_record(run_id) + if rec is None: + # Corrupted run.json: surface it, don't silently skip (spec: unreadable rows). + try: + with _connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO runs (run_id, origin, status, created_at, " + "trainer_node_id) VALUES (?, 'human', 'unreadable', 0, '')", + (run_id,), + ) + count += 1 + except sqlite3.Error: + logger.warning("could not index unreadable run %s", run_id, exc_info=True) + continue + _, series = run_store.load_series(rec.run_id) + upsert_run(rec, run_store.summarize(series)) + count += 1 + return count + + +def reconcile_stale_running(timeout_ms: float = 60_000) -> list[str]: + cutoff = now_ms() - timeout_ms + with _connect() as conn: + stale = [r[0] for r in conn.execute( + "SELECT run_id FROM runs WHERE status IN ('running','queued') " + "AND (last_heartbeat_at IS NULL OR last_heartbeat_at < ?)", (cutoff,) + )] + return _crash_run_ids(stale) + + +def reconcile_all_active_on_startup() -> list[str]: + """Mark every ``running``/``queued`` row ``crashed``, regardless of heartbeat age. + + Call this once, at server startup, before serving traffic: nothing can + legitimately still be executing in a fresh process, so unlike + ``reconcile_stale_running()`` (which only catches rows whose heartbeat is + already older than its timeout) this needs no timeout at all to be correct + here. It closes the gap where a run's heartbeat was still fresh relative to + the timeout window at the moment the previous process crashed. + """ + with _connect() as conn: + active = [r[0] for r in conn.execute( + "SELECT run_id FROM runs WHERE status IN ('running','queued')" + )] + return _crash_run_ids(active) + + +def _crash_run_ids(run_ids: list[str]) -> list[str]: + crashed = [] + for run_id in run_ids: + rec = run_store.read_run_record(run_id) + if rec is None or rec.status not in ("running", "queued"): + continue + rec = rec.model_copy(update={"status": "crashed", "finished_at": now_ms()}) + run_store.write_run_record(rec) + _, series = run_store.load_series(run_id) + upsert_run(rec, run_store.summarize(series)) + crashed.append(run_id) + return crashed + + +def repair_index_if_needed() -> int | None: + """Startup self-heal for ``index.db``: rebuild it if it's corrupt, or if its row + count undercounts the ``run-*/run.json`` directories actually on disk (covers a + missing file too, since ``_connect()`` creates an empty schema for a fresh path). + + Returns the count from ``rebuild_index()`` if a rebuild happened, else ``None``. + """ + on_disk = sum(1 for _ in run_store.runs_root().glob("run-*/run.json")) + try: + with _connect() as conn: + count = conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0] + except sqlite3.DatabaseError: + logger.warning("run index corrupt, rebuilding", exc_info=True) + index_path().unlink(missing_ok=True) + return rebuild_index() + if count < on_disk: + return rebuild_index() + return None diff --git a/comfy_research/engine/runs/run_store.py b/comfy_research/engine/runs/run_store.py new file mode 100644 index 0000000..1518104 --- /dev/null +++ b/comfy_research/engine/runs/run_store.py @@ -0,0 +1,180 @@ +"""File layer of the run store: ``data/runs/{run_id}/`` is the source of truth.""" +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +from typing import Any + +from comfy_research.schemas.run_record import RunRecord +from comfy_research.schemas.train_request import sanitize_train_ndjson_value + +logger = logging.getLogger(__name__) + +_REPO_ROOT = Path(__file__).resolve().parents[3] + +SERIES_KEYS = ("loss_history", "test_loss_history", "reg_loss_history", + "step_ticks", "epoch_ticks") +_RESULT_STRIP_KEYS = frozenset( + {"checkpoint_b64", "plot_png_base64", "visualization_node_ids", + "observable_viz_updates", "observable_embedding_histories", + "observable_attention_slice_histories", "type"} +) + + +def runs_root() -> Path: + env = os.environ.get("COMFYRESEARCH_RUNS_DIR", "").strip() + return Path(env) if env else _REPO_ROOT / "data" / "runs" + + +def run_dir(run_id: str) -> Path: + if not run_id or "/" in run_id or "\\" in run_id or run_id.startswith("."): + raise ValueError(f"invalid run_id: {run_id!r}") + return runs_root() / run_id + + +def _atomic_write_json(path: Path, payload: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + tmp.replace(path) + + +def write_run_record(rec: RunRecord) -> None: + _atomic_write_json(run_dir(rec.run_id) / "run.json", rec.model_dump(mode="json")) + + +def read_run_record(run_id: str) -> RunRecord | None: + path = run_dir(run_id) / "run.json" + try: + return RunRecord.model_validate_json(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def append_metric_rows(run_id: str, rows: list[dict]) -> None: + if not rows: + return + path = run_dir(run_id) / "metrics.ndjson" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as f: + for row in rows: + f.write(json.dumps(sanitize_train_ndjson_value(row), separators=(",", ":")) + "\n") + + +def read_metric_rows(run_id: str) -> list[dict]: + path = run_dir(run_id) / "metrics.ndjson" + if not path.is_file(): + return [] + lines = path.read_text(encoding="utf-8").splitlines() + rows: list[dict] = [] + dropped = 0 + for i, line in enumerate(lines): + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + dropped = len(lines) - i + break # truncated tail from an interrupted append; drop it + if dropped: + logger.warning( + "run %s: dropped %d trailing unparseable line(s) from metrics.ndjson", + run_id, dropped, + ) + return rows + + +def write_results(run_id: str, payload: dict) -> None: + kept = {k: v for k, v in payload.items() if k not in _RESULT_STRIP_KEYS} + _atomic_write_json(run_dir(run_id) / "results.json", + sanitize_train_ndjson_value(kept)) + + +def read_results(run_id: str) -> dict | None: + path = run_dir(run_id) / "results.json" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +def load_series(run_id: str) -> tuple[str, dict]: + """Authority rule: ``results.json`` if present, else series rebuilt from ``metrics.ndjson``. + + Returns ("results" | "ndjson", series-dict). The single place that decides which + source wins — the API, index rebuild, and reconciliation must all use it. + """ + results = read_results(run_id) + if results is not None: + return "results", results + rows = read_metric_rows(run_id) + return "ndjson", { + "loss_history": [r.get("loss") for r in rows], + "test_loss_history": [r["test_loss"] for r in rows if "test_loss" in r], + "reg_loss_history": [r["reg_loss"] for r in rows if "reg_loss" in r], + "step_ticks": [r["step"] for r in rows if "step" in r], + "epoch_ticks": [r["epoch"] for r in rows if "epoch" in r], + } + + +class MetricsDeltaTracker: + """Turn cumulative-history ``metrics`` events into append-only delta rows. + + The trainer re-sends full histories on every emission; appending raw payloads + would be O(n^2) in storage. Track last-seen length, emit only new indices, + keep the latest cumulative snapshot for terminal fallback. + """ + + def __init__(self) -> None: + self._latest: dict[str, Any] = {k: [] for k in SERIES_KEYS} + self._latest["observable_metric_histories"] = {} + self._seen = 0 + + def extract(self, event: dict) -> list[dict]: + if event.get("type") != "metrics": + return [] + for key in SERIES_KEYS: + v = event.get(key) + if isinstance(v, list): + self._latest[key] = list(v) + obs = event.get("observable_metric_histories") + if isinstance(obs, dict): + self._latest["observable_metric_histories"] = { + str(k): list(v) for k, v in obs.items() if isinstance(v, list) + } + loss = self._latest["loss_history"] + rows: list[dict] = [] + for i in range(self._seen, len(loss)): + row: dict[str, Any] = {"idx": i, "loss": loss[i]} + for name, key in (("step", "step_ticks"), ("test_loss", "test_loss_history"), + ("reg_loss", "reg_loss_history"), ("epoch", "epoch_ticks")): + series = self._latest[key] + if i < len(series): + row[name] = series[i] + obs_row = { + k: v[i] + for k, v in self._latest["observable_metric_histories"].items() + if i < len(v) + } + if obs_row: + row["obs"] = obs_row + rows.append(row) + self._seen = len(loss) + return rows + + def snapshot(self) -> dict: + return {k: list(v) if isinstance(v, list) else dict(v) for k, v in self._latest.items()} + + +def summarize(snapshot: dict) -> dict: + loss = snapshot.get("loss_history") or [] + test = [x for x in (snapshot.get("test_loss_history") or []) if isinstance(x, (int, float))] + return { + "final_loss": loss[-1] if loss else None, + "final_test_loss": test[-1] if test else None, + "best_test_loss": min(test) if test else None, + "steps_completed": len(loss), + } diff --git a/comfy_research/engine/runs/run_worker.py b/comfy_research/engine/runs/run_worker.py new file mode 100644 index 0000000..ec58084 --- /dev/null +++ b/comfy_research/engine/runs/run_worker.py @@ -0,0 +1,221 @@ +# comfy_research/engine/runs/run_worker.py +"""Server-owned worker pool: async-submitted runs execute detached from any HTTP stream.""" +from __future__ import annotations + +import logging +import threading +from collections import OrderedDict, deque +from concurrent.futures import ThreadPoolExecutor + +from fastapi import HTTPException + +from comfy_research.engine.runs import run_store +from comfy_research.engine.runs.run_writer import RunWriter, build_run_record, capture_events +from comfy_research.engine.runs.trainer_run import ( + iter_trainer_events_from_context, + prepare_trainer_run, +) +from comfy_research.engine.runs.train_control import request_abort +from comfy_research.engine.runs.ai4science_alias import remap_ai4science_node_types +from comfy_research.schemas.train_request import TrainRequest + +logger = logging.getLogger(__name__) + +_DEFAULT_SLOTS = 2 +_MAX_IDEMPOTENCY_ENTRIES = 4096 + + +def _prefers_remote_gpu(body: TrainRequest) -> bool: + node = next((n for n in body.nodes if n.id == body.trainer_node_id), None) + data = getattr(node, "data", None) or {} + spec = str(data.get("computeDevice", "")).strip().lower() + return (spec == "cuda" or spec.startswith("cuda:")) and data.get("remoteGpu") is True + + +class RunWorkerPool: + def __init__(self, slots: int = _DEFAULT_SLOTS) -> None: + self._executor = ThreadPoolExecutor(max_workers=slots, thread_name_prefix="run-worker") + self._lock = threading.Lock() + self._active: dict[str, str] = {} # trainer_node_id -> run_id currently executing + self._waiting: dict[str, deque[str]] = {} # trainer_node_id -> queued run_ids (FIFO) + self._writers: dict[str, RunWriter] = {} + # run_id -> TrainRequest and RunWriter are both evicted once a run finishes + # (see _execute's finally): TrainRequest can carry a full resume blob, and + # neither is bounded otherwise. Post-completion status is read from disk + # (run_store), not from pool state. + self._idempotency: OrderedDict[str, str] = OrderedDict() + self._idempotency_inflight: dict[str, threading.Event] = {} + self._records: dict[str, TrainRequest] = {} + + def submit(self, body: TrainRequest, idempotency_key: str | None = None): + if _prefers_remote_gpu(body): + raise HTTPException(status_code=400, detail={ + "code": "remote_not_supported", + "detail": "Async submit runs locally only; use streaming POST /api/train for remote GPU runs.", + }) + reserved = False + if idempotency_key: + # Single-flight: only the caller that wins the reservation does the + # prepare/RunWriter work; concurrent same-key callers wait for it and + # then return its record, instead of each racing to persist their own run. + while True: + with self._lock: + if idempotency_key in self._idempotency: + run_id = self._idempotency[idempotency_key] + writer = self._writers.get(run_id) + if writer is not None: + return writer.record + # The run already finished and was evicted from pool state; + # its record still exists on disk (this is why the + # idempotency mapping outlives the writer). + rec = run_store.read_run_record(run_id) + if rec is not None: + return rec + event = self._idempotency_inflight.get(idempotency_key) + if event is None: + self._idempotency_inflight[idempotency_key] = threading.Event() + reserved = True + break + event.wait() # outside the lock; re-check self._idempotency once released + try: + mapped = remap_ai4science_node_types(body.nodes) + prepare_trainer_run( + mapped, body.edges, body.trainer_node_id, + resume=body.resume, + hessian_oversized_policy=body.hessian_oversized_policy, + validate_only=True, + ) + writer = RunWriter(build_run_record(body)) + run_id = writer.record.run_id + trainer_id = body.trainer_node_id + submit_now = False + with self._lock: + self._writers[run_id] = writer + self._records[run_id] = body + if idempotency_key: + self._idempotency[idempotency_key] = run_id + self._idempotency.move_to_end(idempotency_key) + while len(self._idempotency) > _MAX_IDEMPOTENCY_ENTRIES: + self._idempotency.popitem(last=False) # evict oldest + if trainer_id in self._active: + # Same trainer already executing: wait in FIFO (no thread blocked), + # because the train_control registry is a single slot per trainer id. + self._waiting.setdefault(trainer_id, deque()).append(run_id) + else: + self._active[trainer_id] = run_id + submit_now = True + if submit_now: + self._executor.submit(self._execute, run_id) + return writer.record + finally: + if reserved: + # Release the reservation (success or failure) so a waiting caller + # either sees the persisted record or gets to retry itself — a + # validation failure must not leave a poisoned reservation. + with self._lock: + event = self._idempotency_inflight.pop(idempotency_key, None) + if event is not None: + event.set() + + def _execute(self, run_id: str) -> None: + with self._lock: + writer = self._writers.get(run_id) + body = self._records.get(run_id) + if writer is None or body is None: + return + try: + if not writer.is_terminal: + mapped = remap_ai4science_node_types(body.nodes) + ctx = prepare_trainer_run( + mapped, body.edges, body.trainer_node_id, + resume=body.resume, + hessian_oversized_policy=body.hessian_oversized_policy, + ) + for _ in capture_events(iter_trainer_events_from_context(ctx), writer): + pass + except HTTPException as exc: + writer.finalize("failed", error_detail=str(exc.detail)) + except Exception as exc: + logger.warning("submitted run %s crashed", run_id, exc_info=True) + writer.finalize("failed", error_detail=f"{type(exc).__name__}: {exc}") + finally: + self._dispatch_next(body.trainer_node_id) + # Evict both now that the run is terminal: pool state is unbounded + # otherwise, and TrainRequest (self._records) can carry a full resume + # blob. Post-completion reads (status, metrics, idempotency replay) go + # through run_store instead; abort() already returns False once + # self._writers has no entry for run_id, which is correct here since + # the run is finished. + with self._lock: + self._writers.pop(run_id, None) + self._records.pop(run_id, None) + try: + from comfy_research.engine.runs.run_gc import run_gc_once + run_gc_once() + except Exception: + logger.warning("post-run GC failed", exc_info=True) + + def _dispatch_next(self, trainer_id: str) -> None: + with self._lock: + queue = self._waiting.get(trainer_id) + next_id = None + while queue: + candidate = queue.popleft() + w = self._writers.get(candidate) + if w is not None and not w.is_terminal: + next_id = candidate + break + if next_id is None: + self._active.pop(trainer_id, None) + if queue is not None and not queue: + self._waiting.pop(trainer_id, None) + return + self._active[trainer_id] = next_id + self._executor.submit(self._execute, next_id) + + def abort(self, run_id: str) -> bool: + with self._lock: + writer = self._writers.get(run_id) + body = self._records.get(run_id) + if writer is None or body is None or writer.is_terminal: + return False + trainer_id = body.trainer_node_id + queue = self._waiting.get(trainer_id) + if queue is not None and run_id in queue: + queue.remove(run_id) # waiting run: finalize directly, no train_control signal + writer.finalize("aborted") + # This run never reaches _execute (it was still queued), so _execute's + # finally never runs for it: evict here too, or it would sit in pool + # state forever. + self._writers.pop(run_id, None) + self._records.pop(run_id, None) + return True + is_running_here = self._active.get(trainer_id) == run_id + if not is_running_here: + return False + request_abort(trainer_id) # only when THIS run holds the trainer; cooperative + return True + + def shutdown(self, wait: bool = False) -> None: + self._executor.shutdown(wait=wait, cancel_futures=True) + + +_pool: RunWorkerPool | None = None +_pool_lock = threading.Lock() + + +def get_worker_pool() -> RunWorkerPool: + global _pool + with _pool_lock: + if _pool is None: + from comfy_research.engine.runs.run_gc import load_gc_config + _pool = RunWorkerPool(slots=load_gc_config()["worker_slots"]) + return _pool + + +def reset_worker_pool_for_tests() -> None: + global _pool + with _pool_lock: + if _pool is not None: + _pool.shutdown(wait=True) + _pool = None diff --git a/comfy_research/engine/runs/run_writer.py b/comfy_research/engine/runs/run_writer.py new file mode 100644 index 0000000..735b4f1 --- /dev/null +++ b/comfy_research/engine/runs/run_writer.py @@ -0,0 +1,126 @@ +"""RunWriter: persist one training run's lifecycle from its NDJSON event stream.""" +from __future__ import annotations + +import logging +from typing import Any, Iterator + +from comfy_research.engine.runs import run_index, run_store +from comfy_research.schemas.graph import GraphDocument +from comfy_research.schemas.run_record import ( + RunRecord, + TERMINAL_STATUSES, + flatten_hyperparams, + new_run_id, + now_ms, + strip_result_data, +) +from comfy_research.schemas.train_request import TrainRequest + +logger = logging.getLogger(__name__) + +_HEARTBEAT_MIN_INTERVAL_MS = 1000.0 +_TERMINAL_EVENT_STATUS = {"complete": "completed", "aborted": "aborted", + "paused": "paused", "error": "failed"} + + +class RunWriter: + def __init__(self, record: RunRecord) -> None: + self._record = record + self._tracker = run_store.MetricsDeltaTracker() + self._last_heartbeat = 0.0 + run_store.write_run_record(record) + run_index.upsert_run(record) + + @property + def record(self) -> RunRecord: + return self._record + + @property + def is_terminal(self) -> bool: + return self._record.status in TERMINAL_STATUSES + + def _update(self, **changes: Any) -> None: + self._record = self._record.model_copy(update=changes) + run_store.write_run_record(self._record) + + def mark_running(self) -> None: + if self._record.status == "queued": + self._update(status="running", started_at=now_ms()) + run_index.upsert_run(self._record) + + def on_event(self, event: dict) -> None: + if self.is_terminal: + return + etype = str(event.get("type", "")) + rows = self._tracker.extract(event) + if rows: + try: + run_store.append_metric_rows(self._record.run_id, rows) + except OSError: + logger.warning("metric append failed for %s", self._record.run_id, exc_info=True) + now = now_ms() + if now - self._last_heartbeat >= _HEARTBEAT_MIN_INTERVAL_MS: + run_index.touch_heartbeat(self._record.run_id, now) + self._last_heartbeat = now + status = _TERMINAL_EVENT_STATUS.get(etype) + if status is None: + return + if etype in ("complete", "paused"): + run_store.write_results(self._record.run_id, dict(event)) + else: + run_store.write_results(self._record.run_id, self._tracker.snapshot()) + detail = str(event.get("detail", "")) if etype == "error" else "" + self.finalize(status, error_detail=detail) + + def finalize(self, status: str, error_detail: str = "") -> None: + if self.is_terminal: + return + self._update(status=status, finished_at=now_ms(), error_detail=error_detail) + snap = run_store.read_results(self._record.run_id) or self._tracker.snapshot() + run_index.upsert_run(self._record, run_store.summarize(snap)) + + def finalize_disconnect(self) -> None: + if not self.is_terminal: + run_store.write_results(self._record.run_id, self._tracker.snapshot()) + self.finalize("aborted") + + +def build_run_record( + body: TrainRequest, + *, + origin: str | None = None, + group_id: str | None = None, + status: str = "queued", +) -> RunRecord: + nodes = strip_result_data(body.nodes) + trainer = next((n for n in body.nodes if n.id == body.trainer_node_id), None) + device = str((trainer.data or {}).get("computeDevice", "")) if trainer else "" + raw_title = (trainer.data or {}).get("instanceTitle") if trainer else None + trainer_title = raw_title.strip() if isinstance(raw_title, str) else "" + return RunRecord( + run_id=new_run_id(), + origin=origin or body.run_origin, + group_id=group_id if group_id is not None else body.run_group_id, + parent_id=body.run_parent_id, + status=status, + created_at=now_ms(), + trainer_node_id=body.trainer_node_id, + device=device, + trainer_title=trainer_title, + graph=GraphDocument(version=1, nodes=nodes, edges=body.edges), + hyperparams=flatten_hyperparams(body.nodes), + ) + + +def capture_events(events: Iterator[dict], writer: RunWriter) -> Iterator[dict]: + """Tee events into the writer; disconnect (generator close) finalizes as aborted.""" + writer.mark_running() + try: + for event in events: + try: + writer.on_event(event) + except Exception: + logger.warning("run capture failed for %s", writer.record.run_id, exc_info=True) + yield event + finally: + writer.finalize_disconnect() diff --git a/comfy_research/engine/runs/train_coordinate_descent.py b/comfy_research/engine/runs/train_coordinate_descent.py index d6ce7d4..e0a33f1 100644 --- a/comfy_research/engine/runs/train_coordinate_descent.py +++ b/comfy_research/engine/runs/train_coordinate_descent.py @@ -21,8 +21,10 @@ _parse_data_path, _trainer_training_steps_override, ) +from comfy_research.engine.runs.run_writer import RunWriter, build_run_record, capture_events from comfy_research.engine.runs.trainer_run import iter_trainer_events_from_context, prepare_trainer_run from comfy_research.schemas.graph import Edge, Node +from comfy_research.schemas.train_request import TrainRequest MAX_COORDINATE_DESCENT_ROUNDS = 24 MAX_AXIS_VALUES = 128 @@ -210,6 +212,7 @@ def _run_train_once( edges: list[Edge], trainer_node_id: str, training_steps_override: int | None, + group_id: str, ) -> tuple[dict[str, Any] | None, str | None]: nodes_p = _trainer_training_steps_override(nodes, trainer_node_id, training_steps_override) try: @@ -225,14 +228,23 @@ def _run_train_once( return None, d if isinstance(d, str) else str(d) except Exception as e: return None, str(e) - for ev in iter_trainer_events_from_context(ctx): + inner_req = TrainRequest(trainer_node_id=trainer_node_id, nodes=nodes_p, edges=edges) + inner_writer = RunWriter(build_run_record(inner_req, origin="sweep", group_id=group_id)) + event_iter = capture_events(iter_trainer_events_from_context(ctx), inner_writer) + result: tuple[dict[str, Any] | None, str | None] = (None, "no_complete_event") + for ev in event_iter: if ev.get("type") == "complete": - return ev, None + result = (ev, None) + break if ev.get("type") == "aborted": - return None, "training_aborted" + result = (None, "training_aborted") + break if ev.get("type") == "paused": - return None, "training_paused_not_supported_in_tuning" - return None, "no_complete_event" + result = (None, "training_paused_not_supported_in_tuning") + break + if hasattr(event_iter, "close"): + event_iter.close() + return result def _is_better(a: tuple[float, float, float], b: tuple[float, float, float] | None) -> bool: @@ -303,6 +315,7 @@ def iter_coordinate_descent_events(body: TrainCoordinateDescentRequest) -> Itera body.edges, body.trainer_node_id, body.training_steps_override, + session_id, ) eval_index += 1 yield { @@ -358,7 +371,18 @@ def iter_coordinate_descent_events(body: TrainCoordinateDescentRequest) -> Itera resume=None, hessian_oversized_policy="skip", ) - for ev in iter_trainer_events_from_context(ctx): + inner_req = TrainRequest( + trainer_node_id=body.trainer_node_id, + nodes=nodes_p, + edges=body.edges, + ) + inner_writer = RunWriter( + build_run_record(inner_req, origin="sweep", group_id=session_id) + ) + event_iter = capture_events( + iter_trainer_events_from_context(ctx), inner_writer + ) + for ev in event_iter: if ev.get("type") == "complete": complete = ev break @@ -368,6 +392,8 @@ def iter_coordinate_descent_events(body: TrainCoordinateDescentRequest) -> Itera if ev.get("type") == "paused": err = "training_paused_not_supported_in_tuning" break + if hasattr(event_iter, "close"): + event_iter.close() if err is None and complete is None: err = "no_complete_event" if err is None and complete is not None: diff --git a/comfy_research/engine/runs/train_sweep.py b/comfy_research/engine/runs/train_sweep.py index 52749c3..4592ab7 100644 --- a/comfy_research/engine/runs/train_sweep.py +++ b/comfy_research/engine/runs/train_sweep.py @@ -17,8 +17,10 @@ unregister_sweep_session, ) from comfy_research.engine.crl.crl_run import iter_crl_events_from_context, prepare_crl_run +from comfy_research.engine.runs.run_writer import RunWriter, build_run_record, capture_events from comfy_research.engine.runs.trainer_run import iter_trainer_events_from_context, prepare_trainer_run from comfy_research.schemas.graph import Edge, Node, NodeKind +from comfy_research.schemas.train_request import TrainRequest MAX_SWEEP_POINTS = 256 @@ -191,7 +193,7 @@ def validate_sweep_request(body: TrainSweepRequest) -> None: nmap = {n.id: n for n in body.nodes} if body.trainer_node_id not in nmap: raise ValueError("trainer_node_id not found in nodes.") - tk = str(nmap[body.trainer_node_id].type) + tk = nmap[body.trainer_node_id].type if not has_capability(tk, "trainer_runner"): raise ValueError("trainer_node_id must refer to a trainer or crl_trainer node.") @@ -276,7 +278,17 @@ def iter_sweep_events(body: TrainSweepRequest) -> Iterator[dict[str, Any]]: resume=None, hessian_oversized_policy="skip", ) - event_iter = iter_trainer_events_from_context(ctx_sup) + inner_req = TrainRequest( + trainer_node_id=body.trainer_node_id, + nodes=nodes_p, + edges=body.edges, + ) + inner_writer = RunWriter( + build_run_record(inner_req, origin="sweep", group_id=session_id) + ) + event_iter = capture_events( + iter_trainer_events_from_context(ctx_sup), inner_writer + ) complete: dict[str, Any] | None = None for ev in event_iter: if ev.get("type") == "complete": @@ -288,6 +300,8 @@ def iter_sweep_events(body: TrainSweepRequest) -> Iterator[dict[str, Any]]: if ev.get("type") == "paused": err = "training_paused_not_supported_in_sweep" break + if hasattr(event_iter, "close"): + event_iter.close() if err: pass elif complete is None: diff --git a/comfy_research/main.py b/comfy_research/main.py index b1c2184..f694878 100644 --- a/comfy_research/main.py +++ b/comfy_research/main.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from contextlib import asynccontextmanager from pathlib import Path @@ -24,6 +25,7 @@ parametric_path_sampler, pca, predict, + runs, svd, train, user_linear_datasets, @@ -84,6 +86,24 @@ async def _lifespan(_: FastAPI): "After editing frontend/src/, run: cd frontend && npm run build — then reload the tab (Cmd+Shift+R). " "For live dev, run COMFYRESEARCH_PORT= npm run dev in frontend/ and open http://127.0.0.1:5173.", ) + try: + from comfy_research.engine.runs.run_index import ( + reconcile_all_active_on_startup, + repair_index_if_needed, + ) + # Repair the index before reconciling: reconciliation reads rows from it, so a + # missing/corrupt/undercounted index.db must be rebuilt from data/runs/*/run.json + # first, or a fresh process would see (and reconcile) nothing. + repair_index_if_needed() + # Nothing can legitimately still be running in a fresh process: any row still + # `running`/`queued` here belongs to a run whose process died. Unlike the + # heartbeat-timeout check used elsewhere, this must not skip rows whose + # heartbeat happened to be fresh right before the crash. + reconcile_all_active_on_startup() + from comfy_research.engine.runs.run_gc import run_gc_once + run_gc_once() + except Exception: # never block startup on store recovery + logging.getLogger(__name__).warning("run store reconciliation failed", exc_info=True) yield @@ -112,6 +132,7 @@ def create_app() -> FastAPI: app.include_router(user_linear_datasets.router) app.include_router(user_symbolic_func_datasets.router) app.include_router(train.router) + app.include_router(runs.router) app.include_router(collect.router) app.include_router(parametric_path_sampler.router) app.include_router(activation_tensor.router) diff --git a/comfy_research/schemas/run_record.py b/comfy_research/schemas/run_record.py new file mode 100644 index 0000000..5d68c5f --- /dev/null +++ b/comfy_research/schemas/run_record.py @@ -0,0 +1,100 @@ +"""RunRecord: persisted metadata for one training run (``data/runs/{run_id}/run.json``).""" +from __future__ import annotations + +import time +import uuid +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from comfy_research.generated.node_manifest import load_node_manifest +from comfy_research.schemas.graph import GraphDocument, Node + +RunStatus = Literal[ + "queued", "running", "completed", "failed", "aborted", "paused", "crashed", "unreadable" +] +TERMINAL_STATUSES: frozenset[str] = frozenset( + {"completed", "failed", "aborted", "paused", "crashed"} +) + +# Result/UI payloads the browser stashes into node data; never persisted in run configs. +# Superset mirror of frontend/src/graph/graphFileExportTier.ts strip lists. +RUN_RESULT_DATA_KEYS: frozenset[str] = frozenset( + { + "checkpoint_b64", "memoryCheckpoint_b64", + "lossHistory", "testLossHistory", "regLossHistory", + "stepTicks", "epochTicks", "observableMetricHistories", + "lastTrainLoopSeconds", "plotPngBase64", "valueHistory", + "embeddingHistory", "attentionMapFrames", "previewGrid", + "histogramPng", "imageGrid", "runSummary", "lastError", + "lastSweepSummary", "observableEmbeddingHistories", + "observableAttentionSliceHistories", + } +) + +_HYPERPARAM_EXCLUDED_FIELDS = frozenset({"instanceTitle"}) + + +class RunRecord(BaseModel): + run_id: str + schema_version: int = 1 + group_id: str | None = None + parent_id: str | None = None + origin: Literal["human", "agent", "sweep"] = "human" + status: RunStatus = "queued" + created_at: float + started_at: float | None = None + finished_at: float | None = None + trainer_node_id: str + device: str = "" + trainer_title: str = "" + error_detail: str = "" + graph: GraphDocument + hyperparams: dict[str, Any] = Field(default_factory=dict) + + +def now_ms() -> float: + return time.time() * 1000.0 + + +def new_run_id() -> str: + return "run-" + uuid.uuid4().hex[:12] + + +def strip_result_data(nodes: list[Node]) -> list[Node]: + """Copy nodes with result/UI blobs removed from ``data`` (config-only snapshot).""" + out: list[Node] = [] + for n in nodes: + data = {k: v for k, v in (n.data or {}).items() if k not in RUN_RESULT_DATA_KEYS} + out.append(n.model_copy(update={"data": data}, deep=True)) + return out + + +_field_keys_by_type: dict[str, list[str]] | None = None + + +def _declared_field_keys(node_type: str) -> list[str]: + global _field_keys_by_type + if _field_keys_by_type is None: + # Built once, lazily: the manifest is ~233KB, and flatten_hyperparams() calls + # this per node, so re-parsing it per call would make every RunRecord build + # O(nodes x manifest-size) instead of O(nodes). + _field_keys_by_type = { + entry.get("type"): [f["key"] for f in entry.get("fields", [])] + for entry in load_node_manifest() + } + return _field_keys_by_type.get(node_type, []) + + +def flatten_hyperparams(nodes: list[Node]) -> dict[str, Any]: + """``{node_id}.{field}`` -> scalar, for manifest-declared fields present in node data.""" + flat: dict[str, Any] = {} + for n in nodes: + data = n.data or {} + for key in _declared_field_keys(n.type.value): + if key in _HYPERPARAM_EXCLUDED_FIELDS or key not in data: + continue + v = data[key] + if isinstance(v, (bool, int, float, str)): + flat[f"{n.id}.{key}"] = v + return flat diff --git a/comfy_research/schemas/train_request.py b/comfy_research/schemas/train_request.py index 69afa14..d1b7a80 100644 --- a/comfy_research/schemas/train_request.py +++ b/comfy_research/schemas/train_request.py @@ -17,6 +17,9 @@ class TrainRequest(BaseModel): edges: list[Edge] = Field(default_factory=list) resume: dict[str, Any] | None = None hessian_oversized_policy: Literal["skip", "force"] | None = None + run_origin: Literal["human", "agent", "sweep"] = "human" + run_group_id: str | None = None + run_parent_id: str | None = None def sanitize_train_ndjson_value(obj: Any) -> Any: diff --git a/comfy_research/tests/test_coordinate_descent_run_capture.py b/comfy_research/tests/test_coordinate_descent_run_capture.py new file mode 100644 index 0000000..29b8137 --- /dev/null +++ b/comfy_research/tests/test_coordinate_descent_run_capture.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import json + +from fastapi.testclient import TestClient + +from comfy_research.main import app +from comfy_research.engine.runs import run_index +from comfy_research.engine.runs.run_index import RunQuery +from comfy_research.tests.train_test_fixtures import minimal_cpu_train_request + + +def test_coordinate_descent_inner_runs_captured_with_group(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + base = minimal_cpu_train_request() + body = { + "trainer_node_id": base["trainer_node_id"], + "nodes": base["nodes"], + "edges": base["edges"], + "axes": [{"node_id": base["trainer_node_id"], "path": "trainingSteps", + "values": [2, 3]}], + "target_step_ticks": [0, 1], + "target_loss_history": [1.0, 0.5], + "session_id": "coordinate-descent-test-1", + "max_rounds": 1, + } + response = TestClient(app).post("/api/train/coordinate-descent", json=body) + assert response.status_code == 200 + events = [json.loads(l) for l in response.text.splitlines() if l.strip()] + assert any(e["type"] == "tuning_started" for e in events) + assert any(e["type"] == "baseline_evaluated" for e in events) + assert any(e["type"] == "candidate_evaluated" for e in events) + assert any(e["type"] == "tuning_complete" for e in events) + assert not any(e["type"] == "error" for e in events) + + rows, _ = run_index.query_runs(RunQuery(group_id="coordinate-descent-test-1")) + # One baseline evaluation (unmodified graph, trainingSteps=3) plus one candidate + # evaluation per axis value (single axis, values [2, 3], one round). + assert len(rows) == 3 + assert all(r["origin"] == "sweep" and r["status"] == "completed" for r in rows) + steps = sorted( + r["hyperparams"][f"{base['trainer_node_id']}.trainingSteps"] for r in rows + ) + assert steps == [2, 3, 3] diff --git a/comfy_research/tests/test_repro_template_manual_rebuild.py b/comfy_research/tests/test_repro_template_manual_rebuild.py index 27b31fc..b1fb1ac 100644 --- a/comfy_research/tests/test_repro_template_manual_rebuild.py +++ b/comfy_research/tests/test_repro_template_manual_rebuild.py @@ -156,8 +156,9 @@ def test_fig1_rebuild_cyclic_nodes_via_graph_api_shape() -> None: assert ctx_clr.training_steps == 105600 -def test_post_train_accepts_rebuilt_fig1_graph_body() -> None: +def test_post_train_accepts_rebuilt_fig1_graph_body(tmp_path, monkeypatch) -> None: """POST /api/train with rebuilt graph: request validates; prepare runs validate_only via patch.""" + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) nodes, edges = _load_fig1() nodes, edges, ids = _strip_cyclic_schedules(nodes, edges) cbs_id = _add_node(nodes, type_="cyclic_batch_schedule", data=dict(_CBS_DEFAULTS), position={"x": 1, "y": 1}) diff --git a/comfy_research/tests/test_run_gc.py b/comfy_research/tests/test_run_gc.py new file mode 100644 index 0000000..0bf8d79 --- /dev/null +++ b/comfy_research/tests/test_run_gc.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from comfy_research.engine.runs import run_gc, run_index, run_store +from comfy_research.schemas.graph import GraphDocument +from comfy_research.schemas.run_record import RunRecord, new_run_id, now_ms + + +def _seed(status: str, origin: str, finished_ms_ago: float) -> str: + rec = RunRecord( + run_id=new_run_id(), origin=origin, status=status, + created_at=now_ms() - finished_ms_ago - 1000, + finished_at=(now_ms() - finished_ms_ago) if status != "running" else None, + trainer_node_id="t1", graph=GraphDocument(version=1, nodes=[], edges=[]), + ) + run_store.write_run_record(rec) + run_index.upsert_run(rec) + return rec.run_id + + +def test_gc_prunes_only_old_terminal_agent_runs(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + (tmp_path / "config.json").write_text('{"max_runs_agent": 2}', encoding="utf-8") + hour = 3_600_000.0 + keep_human = _seed("completed", "human", 10 * hour) + keep_recent = _seed("completed", "agent", 0.0) # inside grace period + keep_running = _seed("running", "agent", 5 * hour) + newest = _seed("completed", "agent", 1 * hour) + older = _seed("completed", "agent", 2 * hour) + oldest = _seed("completed", "agent", 3 * hour) + + pruned = run_gc.run_gc_once() + # agent rows newest-first by created_at: [keep_recent, newest, older, oldest, keep_running]; + # cap 2 keeps [keep_recent, newest]; over-cap = [older, oldest, keep_running], of which + # only terminal runs past the 10-minute grace period are prunable. + assert set(pruned) == {older, oldest} + for rid in (keep_human, keep_recent, keep_running, newest): + assert run_store.read_run_record(rid) is not None + for rid in pruned: + assert run_store.read_run_record(rid) is None + + +def test_gc_default_config_noop_under_cap(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + _seed("completed", "agent", 3_600_000.0) + assert run_gc.run_gc_once() == [] + + +def test_gc_prunes_only_old_terminal_sweep_runs(tmp_path, monkeypatch) -> None: + """Sweep origin gets the same cap/grace/terminal treatment as agent origin + (sweeps are the highest-volume producer, one run per evaluated point).""" + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + (tmp_path / "config.json").write_text('{"max_runs_sweep": 2}', encoding="utf-8") + hour = 3_600_000.0 + keep_human = _seed("completed", "human", 10 * hour) + keep_agent = _seed("completed", "agent", 10 * hour) # unaffected by the sweep cap + keep_recent = _seed("completed", "sweep", 0.0) # inside grace period + keep_running = _seed("running", "sweep", 5 * hour) + newest = _seed("completed", "sweep", 1 * hour) + older = _seed("completed", "sweep", 2 * hour) + oldest = _seed("completed", "sweep", 3 * hour) + + pruned = run_gc.run_gc_once() + assert set(pruned) == {older, oldest} + for rid in (keep_human, keep_agent, keep_recent, keep_running, newest): + assert run_store.read_run_record(rid) is not None + for rid in pruned: + assert run_store.read_run_record(rid) is None + + +def test_gc_sweep_default_config_noop_under_cap(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + _seed("completed", "sweep", 3_600_000.0) + assert run_gc.run_gc_once() == [] diff --git a/comfy_research/tests/test_run_index.py b/comfy_research/tests/test_run_index.py new file mode 100644 index 0000000..ac815e0 --- /dev/null +++ b/comfy_research/tests/test_run_index.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +from comfy_research.schemas.graph import GraphDocument +from comfy_research.schemas.run_record import RunRecord, new_run_id, now_ms +from comfy_research.engine.runs import run_index, run_store +from comfy_research.engine.runs.run_index import RunQuery + + +def _record(status: str = "running", origin: str = "agent", + group: str | None = None, lr: float = 0.01) -> RunRecord: + return RunRecord( + run_id=new_run_id(), origin=origin, status=status, created_at=now_ms(), + group_id=group, trainer_node_id="t1", + graph=GraphDocument(version=1, nodes=[], edges=[]), + hyperparams={"opt1.lr": lr}, + ) + + +def _seed(monkeypatch, tmp_path): + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + + +def test_upsert_and_query_filters(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + a = _record(status="completed", lr=0.01) + b = _record(status="failed", lr=0.1) + for r in (a, b): + run_store.write_run_record(r) + run_index.upsert_run(r) + rows, cursor = run_index.query_runs(RunQuery(status="completed")) + assert [r["run_id"] for r in rows] == [a.run_id] + assert cursor is None + rows, _ = run_index.query_runs(RunQuery(hyperparams={"opt1.lr": "0.1"})) + assert [r["run_id"] for r in rows] == [b.run_id] + rows, _ = run_index.query_runs(RunQuery(ids=[a.run_id])) + assert rows[0]["hyperparams"] == {"opt1.lr": 0.01} + + +def test_cursor_pagination(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + made = [] + for _ in range(5): + r = _record(status="completed") + run_store.write_run_record(r) + run_index.upsert_run(r) + made.append(r.run_id) + page1, cur1 = run_index.query_runs(RunQuery(limit=2)) + page2, cur2 = run_index.query_runs(RunQuery(limit=2, cursor=cur1)) + page3, cur3 = run_index.query_runs(RunQuery(limit=2, cursor=cur2)) + ids = [r["run_id"] for r in page1 + page2 + page3] + assert sorted(ids) == sorted(made) and len(ids) == 5 + assert cur3 is None + + +def test_summary_columns_and_order_by(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + a, b = _record(status="completed"), _record(status="completed") + for r, loss in ((a, 0.5), (b, 0.1)): + run_store.write_run_record(r) + run_index.upsert_run(r, summary={"final_loss": loss, "final_test_loss": None, + "best_test_loss": None, "steps_completed": 3}) + rows, _ = run_index.query_runs(RunQuery(order_by="final_loss")) + assert [r["run_id"] for r in rows] == [b.run_id, a.run_id] + assert rows[0]["final_loss"] == 0.1 and rows[0]["steps_completed"] == 3 + + +def test_rebuild_surfaces_unreadable_run(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + good = _record(status="completed") + run_store.write_run_record(good) + run_index.upsert_run(good) + bad_dir = tmp_path / "run-corrupted0001" + bad_dir.mkdir() + (bad_dir / "run.json").write_text("{not json", encoding="utf-8") + assert run_index.rebuild_index() == 2 + rows, _ = run_index.query_runs(RunQuery(status="unreadable")) + assert [r["run_id"] for r in rows] == ["run-corrupted0001"] + + +def test_rebuild_from_files(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + recs = [_record(status="completed") for _ in range(3)] + for r in recs: + run_store.write_run_record(r) + run_index.upsert_run(r) + before, _ = run_index.query_runs(RunQuery()) + run_index.index_path().unlink() + assert run_index.rebuild_index() == 3 + after, _ = run_index.query_runs(RunQuery()) + assert {r["run_id"] for r in after} == {r["run_id"] for r in before} + + +def test_reconcile_stale_running(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + stale = _record(status="running") + run_store.write_run_record(stale) + run_index.upsert_run(stale) + run_index.touch_heartbeat(stale.run_id, now_ms() - 120_000) + fresh = _record(status="running") + run_store.write_run_record(fresh) + run_index.upsert_run(fresh) + run_index.touch_heartbeat(fresh.run_id, now_ms()) + + crashed = run_index.reconcile_stale_running(timeout_ms=60_000) + assert crashed == [stale.run_id] + assert run_store.read_run_record(stale.run_id).status == "crashed" + rows, _ = run_index.query_runs(RunQuery(ids=[fresh.run_id])) + assert rows[0]["status"] == "running" + + +def test_reconcile_all_active_on_startup_ignores_heartbeat_age(tmp_path, monkeypatch) -> None: + """A crash where the heartbeat was <60s old must still be reconciled at startup: + reconcile_stale_running()'s default timeout would miss it (regression coverage for + that gap), reconcile_all_active_on_startup() must not.""" + _seed(monkeypatch, tmp_path) + running_fresh_heartbeat = _record(status="running") + run_store.write_run_record(running_fresh_heartbeat) + run_index.upsert_run(running_fresh_heartbeat) + run_index.touch_heartbeat(running_fresh_heartbeat.run_id, now_ms()) # 0s old + + queued_no_heartbeat = _record(status="queued") + run_store.write_run_record(queued_no_heartbeat) + run_index.upsert_run(queued_no_heartbeat) + + already_done = _record(status="completed") + run_store.write_run_record(already_done) + run_index.upsert_run(already_done) + + # The default-timeout path would NOT catch the fresh-heartbeat run: this is the bug + # reconcile_all_active_on_startup() exists to close. + assert run_index.reconcile_stale_running() == [] + + crashed = run_index.reconcile_all_active_on_startup() + assert set(crashed) == {running_fresh_heartbeat.run_id, queued_no_heartbeat.run_id} + assert run_store.read_run_record(running_fresh_heartbeat.run_id).status == "crashed" + assert run_store.read_run_record(queued_no_heartbeat.run_id).status == "crashed" + assert run_store.read_run_record(already_done.run_id).status == "completed" + + +def test_repair_index_if_needed_rebuilds_after_index_deleted(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + a = _record(status="completed") + b = _record(status="completed") + for r in (a, b): + run_store.write_run_record(r) + run_index.upsert_run(r) + + run_index.index_path().unlink() + count = run_index.repair_index_if_needed() + + assert count == 2 + rows, _ = run_index.query_runs(RunQuery()) + assert {r["run_id"] for r in rows} == {a.run_id, b.run_id} + + +def test_repair_index_if_needed_rebuilds_corrupt_index(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + a = _record(status="completed") + run_store.write_run_record(a) + run_index.upsert_run(a) + + # A leftover WAL/SHM sidecar from the healthy connection above would let sqlite + # transparently recover past a corrupted main file, masking the case this guards; + # drop them too so the corruption is actually unrecoverable, the way a truncated or + # hand-copied single index.db file would be in production. + for suffix in ("", "-wal", "-shm"): + path = run_index.index_path().with_name(run_index.index_path().name + suffix) + path.unlink(missing_ok=True) + run_index.index_path().write_bytes(b"not a sqlite file at all") + count = run_index.repair_index_if_needed() + + assert count == 1 + rows, _ = run_index.query_runs(RunQuery()) + assert [r["run_id"] for r in rows] == [a.run_id] + + +def test_repair_index_if_needed_is_a_noop_when_index_is_current(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + a = _record(status="completed") + run_store.write_run_record(a) + run_index.upsert_run(a) + + assert run_index.repair_index_if_needed() is None + + +def test_group_summary_and_delete(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + a = _record(status="completed", group="g1") + b = _record(status="failed", group="g1") + for r in (a, b): + run_store.write_run_record(r) + run_index.upsert_run(r) + groups = run_index.group_summary() + g1 = next(g for g in groups if g["group_id"] == "g1") + assert g1["counts"] == {"completed": 1, "failed": 1} + run_index.delete_rows([a.run_id, b.run_id]) + rows, _ = run_index.query_runs(RunQuery(group_id="g1")) + assert rows == [] + + +def test_cursor_pagination_with_nulls(tmp_path, monkeypatch) -> None: + """Test cursor pagination with NULL values in order-by column. + + Regression test for crash when cursor ends on row with NULL order key. + Ensures no dropped or duplicated rows across pages. + """ + _seed(monkeypatch, tmp_path) + # Create 3 runs with NULL final_loss and 2 with values + null_runs = [] + for _ in range(3): + r = _record(status="completed") + run_store.write_run_record(r) + run_index.upsert_run(r, summary={"final_loss": None, "final_test_loss": None, + "best_test_loss": None, "steps_completed": 0}) + null_runs.append(r.run_id) + + value_runs = [] + for i, loss in enumerate([0.5, 0.1]): + r = _record(status="completed") + run_store.write_run_record(r) + run_index.upsert_run(r, summary={"final_loss": loss, "final_test_loss": None, + "best_test_loss": None, "steps_completed": 1}) + value_runs.append(r.run_id) + + all_runs = value_runs + null_runs + + # Query with order_by final_loss (descending), paginate with limit=2 + page1, cur1 = run_index.query_runs(RunQuery(order_by="-final_loss", limit=2)) + page2, cur2 = run_index.query_runs(RunQuery(order_by="-final_loss", limit=2, cursor=cur1)) + page3, cur3 = run_index.query_runs(RunQuery(order_by="-final_loss", limit=2, cursor=cur2)) + + # Collect all pages + pages = [page1, page2, page3] + all_pages = [] + for p in pages: + all_pages.extend([r["run_id"] for r in p]) + + # Assertions + assert len(all_pages) == 5, f"Expected 5 rows, got {len(all_pages)}" + assert sorted(all_pages) == sorted(all_runs), f"Mismatch in collected rows" + assert len(set(all_pages)) == len(all_pages), "Duplicate rows found across pages" + + # Verify NULL rows come after non-NULL ones (DESC sort means high to low, NULLs last) + flat_rows = page1 + page2 + page3 + non_null_indices = [i for i, r in enumerate(flat_rows) if r["final_loss"] is not None] + null_indices = [i for i, r in enumerate(flat_rows) if r["final_loss"] is None] + + if non_null_indices and null_indices: + assert max(non_null_indices) < min(null_indices), \ + "NULL rows should come after non-NULL rows (NULLS LAST)" + + +def test_legacy_index_without_trainer_title_auto_migrates(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + # Build an index with the PRE-trainer_title schema and one row, bypassing _connect. + import sqlite3 + + tmp_path.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(tmp_path / "index.db") + conn.executescript( + """ + CREATE TABLE runs ( + run_id TEXT PRIMARY KEY, group_id TEXT, parent_id TEXT, + origin TEXT NOT NULL, status TEXT NOT NULL, + created_at REAL NOT NULL, started_at REAL, finished_at REAL, + last_heartbeat_at REAL, trainer_node_id TEXT NOT NULL, + device TEXT DEFAULT '', error_detail TEXT DEFAULT '', + hyperparams_json TEXT NOT NULL DEFAULT '{}', + final_loss REAL, final_test_loss REAL, best_test_loss REAL, + steps_completed INTEGER, duration_seconds REAL + ); + """ + ) + conn.execute( + "INSERT INTO runs (run_id, origin, status, created_at, trainer_node_id) " + "VALUES ('run-legacy000001', 'human', 'completed', 1.0, 't1')" + ) + conn.commit() + conn.close() + + rows, _ = run_index.query_runs(RunQuery()) + assert [r["run_id"] for r in rows] == ["run-legacy000001"] + assert rows[0]["trainer_title"] == "" + + +def test_trainer_title_round_trips_through_index(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + rec = _record(status="completed").model_copy(update={"trainer_title": "Grokking Trainer"}) + run_store.write_run_record(rec) + run_index.upsert_run(rec) + rows, _ = run_index.query_runs(RunQuery(ids=[rec.run_id])) + assert rows[0]["trainer_title"] == "Grokking Trainer" diff --git a/comfy_research/tests/test_run_record.py b/comfy_research/tests/test_run_record.py new file mode 100644 index 0000000..e59c82c --- /dev/null +++ b/comfy_research/tests/test_run_record.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from comfy_research.schemas.graph import Edge, GraphDocument, Node +from comfy_research.schemas.run_record import ( + RUN_RESULT_DATA_KEYS, + RunRecord, + flatten_hyperparams, + new_run_id, + now_ms, + strip_result_data, +) + + +def _trainer_node() -> Node: + return Node( + id="t1", + type="trainer", + data={ + "trainingSteps": 4, + "computeDevice": "cpu", + "instanceTitle": "Trainer", + "lossHistory": [1.0, 0.5], + "memoryCheckpoint_b64": "QUJD", + "plotPngBase64": "aW1n", + }, + ) + + +def test_new_run_id_prefix_and_uniqueness() -> None: + a, b = new_run_id(), new_run_id() + assert a.startswith("run-") and len(a) == 16 + assert a != b + + +def test_strip_result_data_removes_blobs_keeps_config() -> None: + stripped = strip_result_data([_trainer_node()]) + data = stripped[0].data + assert data["trainingSteps"] == 4 + assert data["instanceTitle"] == "Trainer" + assert "lossHistory" not in data + assert "memoryCheckpoint_b64" not in data + assert "plotPngBase64" not in data + # original untouched + assert "lossHistory" in _trainer_node().data + + +def test_result_keys_cover_known_blobs() -> None: + for key in ("checkpoint_b64", "memoryCheckpoint_b64", "plotPngBase64", + "lossHistory", "testLossHistory", "regLossHistory", "stepTicks", + "observableMetricHistories", "embeddingHistory", + "attentionMapFrames", "valueHistory", "runSummary", "lastError"): + assert key in RUN_RESULT_DATA_KEYS + + +def test_flatten_hyperparams_declared_scalars_only() -> None: + flat = flatten_hyperparams([_trainer_node()]) + assert flat["t1.trainingSteps"] == 4 + assert flat["t1.computeDevice"] == "cpu" + assert "t1.lossHistory" not in flat # not a declared field + assert "t1.instanceTitle" not in flat # declared but excluded as label + + +def test_run_record_roundtrip() -> None: + rec = RunRecord( + run_id=new_run_id(), + origin="agent", + status="queued", + created_at=now_ms(), + trainer_node_id="t1", + graph=GraphDocument(version=1, nodes=strip_result_data([_trainer_node()]), edges=[]), + hyperparams=flatten_hyperparams([_trainer_node()]), + ) + again = RunRecord.model_validate(rec.model_dump(mode="json")) + assert again.run_id == rec.run_id + assert again.schema_version == 1 + assert again.group_id is None and again.finished_at is None diff --git a/comfy_research/tests/test_run_store.py b/comfy_research/tests/test_run_store.py new file mode 100644 index 0000000..f36abf7 --- /dev/null +++ b/comfy_research/tests/test_run_store.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from comfy_research.schemas.graph import GraphDocument +from comfy_research.schemas.run_record import RunRecord, new_run_id, now_ms +from comfy_research.engine.runs import run_store + + +def _record(run_id: str) -> RunRecord: + return RunRecord( + run_id=run_id, origin="agent", status="running", created_at=now_ms(), + trainer_node_id="t1", graph=GraphDocument(version=1, nodes=[], edges=[]), + ) + + +def _metrics_event(n: int) -> dict: + return { + "type": "metrics", "step": n, + "loss_history": [1.0 / (i + 1) for i in range(n)], + "test_loss_history": [2.0 / (i + 1) for i in range(n)], + "reg_loss_history": [], + "step_ticks": list(range(n)), + "epoch_ticks": [], + "observable_metric_histories": {"obs1:acc": [float(i) for i in range(n)]}, + "observable_warnings": {}, + } + + +def test_runs_root_env_override(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path / "r")) + assert run_store.runs_root() == tmp_path / "r" + + +def test_record_roundtrip_and_atomicity(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + rid = new_run_id() + run_store.write_run_record(_record(rid)) + rec = run_store.read_run_record(rid) + assert rec is not None and rec.run_id == rid + assert not list((tmp_path / rid).glob("*.tmp")) + assert run_store.read_run_record("run-missing00000") is None + + +def test_delta_tracker_appends_only_new_rows() -> None: + tracker = run_store.MetricsDeltaTracker() + rows1 = tracker.extract(_metrics_event(2)) + rows2 = tracker.extract(_metrics_event(5)) + assert len(rows1) == 2 and len(rows2) == 3 + assert rows2[0]["idx"] == 2 and rows2[0]["step"] == 2 + assert rows2[-1]["loss"] == 1.0 / 5 + assert rows2[-1]["obs"]["obs1:acc"] == 4.0 + snap = tracker.snapshot() + assert len(snap["loss_history"]) == 5 + + +def test_delta_tracker_ignores_non_metrics_events() -> None: + tracker = run_store.MetricsDeltaTracker() + assert tracker.extract({"type": "progress", "step": 1, "total": 4}) == [] + + +def test_metrics_ndjson_roundtrip_tolerates_truncated_tail(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + rid = new_run_id() + run_store.write_run_record(_record(rid)) + run_store.append_metric_rows(rid, [{"idx": 0, "loss": 1.0}, {"idx": 1, "loss": 0.5}]) + path = tmp_path / rid / "metrics.ndjson" + path.write_text(path.read_text() + '{"idx": 2, "lo', encoding="utf-8") + rows = run_store.read_metric_rows(rid) + assert [r["idx"] for r in rows] == [0, 1] + + +def test_results_sanitized_and_stripped(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + rid = new_run_id() + run_store.write_run_record(_record(rid)) + run_store.write_results(rid, { + "loss_history": [1.0, float("nan")], + "checkpoint_b64": "QUJD", + "plot_png_base64": "aW1n", + "observable_embedding_histories": {"a": [1]}, + }) + res = run_store.read_results(rid) + assert res == {"loss_history": [1.0, None]} + + +def test_summarize() -> None: + tracker = run_store.MetricsDeltaTracker() + tracker.extract(_metrics_event(4)) + s = run_store.summarize(tracker.snapshot()) + assert s["final_loss"] == 0.25 + assert s["best_test_loss"] == 0.5 + assert s["steps_completed"] == 4 diff --git a/comfy_research/tests/test_run_worker.py b/comfy_research/tests/test_run_worker.py new file mode 100644 index 0000000..85fedb9 --- /dev/null +++ b/comfy_research/tests/test_run_worker.py @@ -0,0 +1,136 @@ +# comfy_research/tests/test_run_worker.py +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor + +import pytest +from fastapi import HTTPException + +from comfy_research.engine.runs import run_store +from comfy_research.engine.runs.run_worker import get_worker_pool, reset_worker_pool_for_tests +from comfy_research.schemas.train_request import TrainRequest +from comfy_research.tests.train_test_fixtures import minimal_cpu_train_request + + +def _wait_terminal(run_id: str, timeout_s: float = 30.0) -> str: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + rec = run_store.read_run_record(run_id) + if rec and rec.status not in ("queued", "running"): + return rec.status + time.sleep(0.05) + raise AssertionError(f"run {run_id} never reached terminal state") + + +@pytest.fixture(autouse=True) +def _isolated(tmp_path, monkeypatch): + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + reset_worker_pool_for_tests() + yield + reset_worker_pool_for_tests() + + +def test_submit_runs_to_completion() -> None: + body = TrainRequest.model_validate({**minimal_cpu_train_request(), "run_origin": "agent"}) + rec = get_worker_pool().submit(body) + assert rec.status == "queued" and rec.origin == "agent" + assert _wait_terminal(rec.run_id) == "completed" + assert len(run_store.read_results(rec.run_id)["loss_history"]) == 4 + + +def test_submit_invalid_graph_raises_400_and_persists_nothing(tmp_path) -> None: + body = TrainRequest.model_validate({ + "trainer_node_id": "trainer", + "nodes": [{"id": "trainer", "type": "trainer", + "data": {"trainingSteps": 1, "computeDevice": "cpu"}}], + "edges": [], + }) + with pytest.raises(HTTPException) as exc: + get_worker_pool().submit(body) + assert exc.value.status_code == 400 + assert not list(tmp_path.glob("run-*")) + + +def test_idempotency_key_returns_same_run() -> None: + body = TrainRequest.model_validate(minimal_cpu_train_request()) + a = get_worker_pool().submit(body, idempotency_key="k1") + b = get_worker_pool().submit(body, idempotency_key="k1") + assert a.run_id == b.run_id + _wait_terminal(a.run_id) + + +def test_concurrent_submits_with_same_idempotency_key_single_flight(tmp_path) -> None: + pool = get_worker_pool() + body = TrainRequest.model_validate(minimal_cpu_train_request()) + n = 4 + with ThreadPoolExecutor(max_workers=n) as tpe: + futures = [tpe.submit(pool.submit, body, "concurrent-key") for _ in range(n)] + records = [f.result(timeout=30.0) for f in futures] + run_ids = {rec.run_id for rec in records} + assert len(run_ids) == 1, f"expected a single run_id, got {run_ids}" + run_id = next(iter(run_ids)) + assert _wait_terminal(run_id) == "completed" + assert list(tmp_path.glob("run-*")) == [tmp_path / run_id] + + +def test_same_trainer_runs_serialize() -> None: + pool = get_worker_pool() + body = TrainRequest.model_validate(minimal_cpu_train_request()) + a = pool.submit(body) + b = pool.submit(body) + assert _wait_terminal(a.run_id) == "completed" + assert _wait_terminal(b.run_id) == "completed" + + +def test_abort_waiting_run_is_scoped_to_that_run() -> None: + pool = get_worker_pool() + slow = minimal_cpu_train_request() + for n in slow["nodes"]: + if n["id"] == slow["trainer_node_id"]: + n["data"] = {**n["data"], "trainingSteps": 2000} + a = pool.submit(TrainRequest.model_validate(slow)) + b = pool.submit(TrainRequest.model_validate(slow)) # waits in FIFO behind a (same trainer) + assert pool.abort(b.run_id) is True + # b dies immediately, without any train_control signal that could hit a + assert run_store.read_run_record(b.run_id).status == "aborted" + assert run_store.read_run_record(a.run_id).status in ("queued", "running") + # abort the running run; cooperative signal can land before the training loop + # registers the trainer, so retry until it takes effect + deadline = time.monotonic() + 30.0 + while time.monotonic() < deadline: + pool.abort(a.run_id) + rec = run_store.read_run_record(a.run_id) + if rec.status not in ("queued", "running"): + break + time.sleep(0.1) + assert run_store.read_run_record(a.run_id).status == "aborted" + assert pool.abort("run-nonexistent0") is False + + +def _renamed_fixture(suffix: str) -> dict: + """Same minimal graph under fresh ids, so it counts as a different trainer.""" + req = minimal_cpu_train_request() + for n in req["nodes"]: + n["id"] = n["id"] + suffix + for e in req["edges"]: + e["id"] = e["id"] + suffix + e["source"] = e["source"] + suffix + e["target"] = e["target"] + suffix + req["trainer_node_id"] = req["trainer_node_id"] + suffix + return req + + +def test_different_trainer_not_blocked_by_same_trainer_queue() -> None: + pool = get_worker_pool() + slow = minimal_cpu_train_request() + for n in slow["nodes"]: + if n["id"] == slow["trainer_node_id"]: + n["data"] = {**n["data"], "trainingSteps": 2000} + a = pool.submit(TrainRequest.model_validate(slow)) + b = pool.submit(TrainRequest.model_validate(slow)) # waits in FIFO, occupies no slot + fast = pool.submit(TrainRequest.model_validate(_renamed_fixture("-x"))) + # with head-of-line blocking, `fast` would be stuck behind b in the 2-slot pool + assert _wait_terminal(fast.run_id) == "completed" + for rid in (a.run_id, b.run_id): + pool.abort(rid) diff --git a/comfy_research/tests/test_run_writer.py b/comfy_research/tests/test_run_writer.py new file mode 100644 index 0000000..1f4346c --- /dev/null +++ b/comfy_research/tests/test_run_writer.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from comfy_research.schemas.graph import GraphDocument +from comfy_research.schemas.run_record import RunRecord, new_run_id, now_ms +from comfy_research.engine.runs import run_index, run_store +from comfy_research.engine.runs.run_index import RunQuery +from comfy_research.engine.runs.run_writer import RunWriter, capture_events + + +def _writer(monkeypatch, tmp_path) -> RunWriter: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + return RunWriter(RunRecord( + run_id=new_run_id(), origin="agent", status="queued", created_at=now_ms(), + trainer_node_id="t1", graph=GraphDocument(version=1, nodes=[], edges=[]), + )) + + +def _events() -> list[dict]: + return [ + {"type": "progress", "step": 0, "total": 2}, + {"type": "metrics", "step": 2, "loss_history": [1.0, 0.5], + "test_loss_history": [], "reg_loss_history": [], "step_ticks": [0, 1], + "epoch_ticks": [], "observable_metric_histories": {}, "observable_warnings": {}}, + {"type": "complete", "checkpoint_b64": "QUJD", "plot_png_base64": "aW1n", + "loss_history": [1.0, 0.5, 0.25], "test_loss_history": [], + "reg_loss_history": [], "step_ticks": [0, 1, 2], "epoch_ticks": [], + "observable_viz_updates": [], "observable_metric_histories": {}, + "observable_embedding_histories": {}, "observable_attention_slice_histories": {}, + "observable_warnings": {}, "train_loop_seconds": 0.1, + "visualization_node_ids": []}, + ] + + +def test_complete_flow(monkeypatch, tmp_path) -> None: + w = _writer(monkeypatch, tmp_path) + seen = list(capture_events(iter(_events()), w)) + assert [e["type"] for e in seen] == ["progress", "metrics", "complete"] + rec = run_store.read_run_record(w.record.run_id) + assert rec.status == "completed" and rec.started_at and rec.finished_at + res = run_store.read_results(rec.run_id) + assert res["loss_history"] == [1.0, 0.5, 0.25] + assert "checkpoint_b64" not in res and "plot_png_base64" not in res + rows, _ = run_index.query_runs(RunQuery(ids=[rec.run_id])) + assert rows[0]["status"] == "completed" and rows[0]["final_loss"] == 0.25 + assert len(run_store.read_metric_rows(rec.run_id)) == 2 + + +def test_disconnect_mid_stream_finalizes_aborted(monkeypatch, tmp_path) -> None: + w = _writer(monkeypatch, tmp_path) + gen = capture_events(iter(_events()), w) + next(gen) + next(gen) # consumed progress + metrics, then client goes away + gen.close() + rec = run_store.read_run_record(w.record.run_id) + assert rec.status == "aborted" + assert run_store.read_results(rec.run_id)["loss_history"] == [1.0, 0.5] + + +def test_error_event_finalizes_failed(monkeypatch, tmp_path) -> None: + w = _writer(monkeypatch, tmp_path) + list(capture_events(iter([{"type": "error", "detail": "boom"}]), w)) + rec = run_store.read_run_record(w.record.run_id) + assert rec.status == "failed" and rec.error_detail == "boom" + + +def test_finalize_idempotent(monkeypatch, tmp_path) -> None: + w = _writer(monkeypatch, tmp_path) + list(capture_events(iter(_events()), w)) + w.finalize_disconnect() # no-op after completed + assert run_store.read_run_record(w.record.run_id).status == "completed" + + +def test_build_run_record_extracts_trainer_title() -> None: + from comfy_research.engine.runs.run_writer import build_run_record + from comfy_research.schemas.train_request import TrainRequest + from comfy_research.tests.train_test_fixtures import minimal_cpu_train_request + + body = minimal_cpu_train_request() + for n in body["nodes"]: + if n["id"] == body["trainer_node_id"]: + n["data"] = {**n["data"], "instanceTitle": "My Trainer"} + rec = build_run_record(TrainRequest.model_validate(body)) + assert rec.trainer_title == "My Trainer" + + rec2 = build_run_record(TrainRequest.model_validate(minimal_cpu_train_request())) + assert rec2.trainer_title == "" + + body3 = minimal_cpu_train_request() + for n in body3["nodes"]: + if n["id"] == body3["trainer_node_id"]: + n["data"] = {**n["data"], "instanceTitle": None} + rec3 = build_run_record(TrainRequest.model_validate(body3)) + assert rec3.trainer_title == "" # None must not become the string "None" diff --git a/comfy_research/tests/test_runs_api.py b/comfy_research/tests/test_runs_api.py new file mode 100644 index 0000000..97678ba --- /dev/null +++ b/comfy_research/tests/test_runs_api.py @@ -0,0 +1,179 @@ +# comfy_research/tests/test_runs_api.py +from __future__ import annotations + +import time + +import pytest +from fastapi.testclient import TestClient + +from comfy_research.main import app +from comfy_research.engine.runs import run_index, run_store +from comfy_research.engine.runs.run_worker import reset_worker_pool_for_tests +from comfy_research.schemas.graph import GraphDocument +from comfy_research.schemas.run_record import RunRecord, new_run_id, now_ms +from comfy_research.tests.train_test_fixtures import minimal_cpu_train_request + + +@pytest.fixture(autouse=True) +def _isolated(tmp_path, monkeypatch): + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + reset_worker_pool_for_tests() + yield + reset_worker_pool_for_tests() + + +def _seed_terminal(status: str = "completed", group: str | None = None) -> str: + rec = RunRecord( + run_id=new_run_id(), origin="agent", status=status, created_at=now_ms(), + finished_at=now_ms(), group_id=group, trainer_node_id="t1", + graph=GraphDocument(version=1, nodes=[], edges=[]), + hyperparams={"t1.trainingSteps": 4}, + trainer_title="Seeded Trainer", + ) + run_store.write_run_record(rec) + run_store.write_results(rec.run_id, {"loss_history": [1.0, 0.5], + "test_loss_history": [], "step_ticks": [0, 1]}) + run_index.upsert_run(rec, {"final_loss": 0.5, "final_test_loss": None, + "best_test_loss": None, "steps_completed": 2}) + return rec.run_id + + +def _wait_terminal(client: TestClient, run_id: str, timeout_s: float = 30.0) -> str: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + status = client.get(f"/api/runs/{run_id}").json()["status"] + if status not in ("queued", "running"): + return status + time.sleep(0.05) + raise AssertionError("never terminal") + + +def test_submit_then_poll_lifecycle() -> None: + client = TestClient(app) + resp = client.post("/api/runs", json={**minimal_cpu_train_request(), "run_origin": "agent"}) + assert resp.status_code == 202 + run_id = resp.json()["run_id"] + assert _wait_terminal(client, run_id) == "completed" + metrics = client.get(f"/api/runs/{run_id}/metrics").json() + assert metrics["source"] == "results" + assert len(metrics["data"]["loss_history"]) == 4 + + +def test_submit_invalid_graph_400() -> None: + resp = TestClient(app).post("/api/runs", json={ + "trainer_node_id": "trainer", + "nodes": [{"id": "trainer", "type": "trainer", + "data": {"trainingSteps": 1, "computeDevice": "cpu"}}], + "edges": [], + }) + assert resp.status_code == 400 + + +def test_list_filters_and_hyperparam() -> None: + client = TestClient(app) + a = _seed_terminal("completed", group="g1") + _seed_terminal("failed", group="g1") + body = client.get("/api/runs", params={"status": "completed"}).json() + assert [r["run_id"] for r in body["runs"]] == [a] + assert body["runs"][0]["trainer_title"] == "Seeded Trainer" + body = client.get("/api/runs", params={"hyperparam.t1.trainingSteps": "4"}).json() + assert len(body["runs"]) == 2 + groups = client.get("/api/runs/groups").json()["groups"] + assert groups[0]["group_id"] == "g1" and groups[0]["counts"]["failed"] == 1 + + +def test_get_missing_run_404_with_code() -> None: + resp = TestClient(app).get("/api/runs/run-doesnotexist") + assert resp.status_code == 404 + assert resp.json()["detail"]["code"] == "run_not_found" + + +def test_malformed_query_params_return_structured_400() -> None: + client = TestClient(app) + resp = client.get("/api/runs", params={"limit": "abc"}) + assert resp.status_code == 400 + assert resp.json()["detail"]["code"] == "invalid_query_param" + + resp = client.get("/api/runs", params={"since": "xyz"}) + assert resp.status_code == 400 + assert resp.json()["detail"]["code"] == "invalid_query_param" + + resp = client.delete("/api/runs", params={"since": "xyz"}) + assert resp.status_code == 400 + + +def test_delete_guards() -> None: + client = TestClient(app) + rid = _seed_terminal("completed") + assert client.delete("/api/runs").status_code == 400 + running = RunRecord( + run_id=new_run_id(), origin="agent", status="running", created_at=now_ms(), + trainer_node_id="t1", graph=GraphDocument(version=1, nodes=[], edges=[]), + ) + run_store.write_run_record(running) + run_index.upsert_run(running) + run_index.touch_heartbeat(running.run_id, now_ms()) + assert client.delete(f"/api/runs/{running.run_id}").status_code == 409 + assert client.delete(f"/api/runs/{rid}").status_code == 200 + assert run_store.read_run_record(rid) is None + resp = client.delete("/api/runs", params={"status": "failed"}) + assert resp.status_code == 200 and resp.json()["deleted"] == 0 + + +def test_bulk_delete_by_group() -> None: + client = TestClient(app) + _seed_terminal("completed", group="g2") + _seed_terminal("aborted", group="g2") + resp = client.delete("/api/runs", params={"group_id": "g2"}) + assert resp.json()["deleted"] == 2 + + +def test_path_traversal_shaped_run_id_never_500s() -> None: + client = TestClient(app) + # Percent-encoded "../x": must never surface an unstructured 500, however the + # router ends up matching (or failing to match, i.e. 404) the encoded segment. + for path in ("/api/runs/..%2Fx", "/api/runs/..%2Fx/metrics"): + resp = client.get(path) + assert resp.status_code in (400, 404), (path, resp.status_code, resp.text) + if resp.status_code == 400: + assert resp.json()["detail"]["code"] == "invalid_run_id" + resp = client.post("/api/runs/..%2Fx/abort") + assert resp.status_code in (400, 404, 405), resp.text + if resp.status_code == 400: + assert resp.json()["detail"]["code"] == "invalid_run_id" + + +def test_malformed_run_id_path_param_returns_400_not_500() -> None: + client = TestClient(app) + # Backslash survives routing as a single path segment (unlike "/"), so this + # reliably reaches the handler and exercises _validate_run_id directly. + resp = client.get("/api/runs/..\\escape") + assert resp.status_code == 400 + assert resp.json()["detail"]["code"] == "invalid_run_id" + + resp = client.get("/api/runs/.hidden") + assert resp.status_code == 400 + assert resp.json()["detail"]["code"] == "invalid_run_id" + + resp = client.post("/api/runs/..\\escape/abort") + assert resp.status_code == 400, resp.text + assert resp.json()["detail"]["code"] == "invalid_run_id" + + resp = client.delete("/api/runs/..\\escape") + assert resp.status_code == 400 + assert resp.json()["detail"]["code"] == "invalid_run_id" + + resp = client.get("/api/runs/..\\escape/metrics") + assert resp.status_code == 400 + assert resp.json()["detail"]["code"] == "invalid_run_id" + + +def test_malformed_cursor_returns_structured_400_not_500() -> None: + client = TestClient(app) + resp = client.get("/api/runs", params={"cursor": "not-a-real-cursor"}) + assert resp.status_code == 400 + assert resp.json()["detail"]["code"] == "invalid_cursor" + + resp = client.get("/api/runs", params={"cursor": "v:not-a-number:run-abc"}) + assert resp.status_code == 400 + assert resp.json()["detail"]["code"] == "invalid_cursor" diff --git a/comfy_research/tests/test_sweep_run_capture.py b/comfy_research/tests/test_sweep_run_capture.py new file mode 100644 index 0000000..bb44c2a --- /dev/null +++ b/comfy_research/tests/test_sweep_run_capture.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json + +from fastapi.testclient import TestClient + +from comfy_research.main import app +from comfy_research.engine.runs import run_index +from comfy_research.engine.runs.run_index import RunQuery +from comfy_research.tests.train_test_fixtures import minimal_cpu_train_request + + +def test_sweep_inner_runs_captured_with_group(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + base = minimal_cpu_train_request() + body = { + "sweep_session_id": "sweep-test-1", + "trainer_node_id": base["trainer_node_id"], + "nodes": base["nodes"], + "edges": base["edges"], + "axes": [{"node_id": base["trainer_node_id"], "path": "trainingSteps", + "values": [2, 3]}], + "metric": {"kind": "final_train_loss"}, + } + response = TestClient(app).post("/api/train/sweep", json=body) + assert response.status_code == 200 + events = [json.loads(l) for l in response.text.splitlines() if l.strip()] + assert any(e["type"] == "sweep_complete" for e in events) + + rows, _ = run_index.query_runs(RunQuery(group_id="sweep-test-1")) + assert len(rows) == 2 + assert all(r["origin"] == "sweep" and r["status"] == "completed" for r in rows) + steps = sorted(r["hyperparams"][f"{base['trainer_node_id']}.trainingSteps"] for r in rows) + assert steps == [2, 3] diff --git a/comfy_research/tests/test_train_api_integration.py b/comfy_research/tests/test_train_api_integration.py index 9e7b086..c200351 100644 --- a/comfy_research/tests/test_train_api_integration.py +++ b/comfy_research/tests/test_train_api_integration.py @@ -13,7 +13,8 @@ def _ndjson_events(response_text: str) -> list[dict[str, object]]: return [json.loads(line) for line in response_text.splitlines() if line.strip()] -def test_post_train_streams_real_cpu_training_result() -> None: +def test_post_train_streams_real_cpu_training_result(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) response = TestClient(app).post("/api/train", json=minimal_cpu_train_request()) assert response.status_code == 200 @@ -35,7 +36,8 @@ def test_post_train_streams_real_cpu_training_result() -> None: ) -def test_post_train_rejects_invalid_graph_before_streaming() -> None: +def test_post_train_rejects_invalid_graph_before_streaming(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) response = TestClient(app).post( "/api/train", json={ diff --git a/comfy_research/tests/test_train_run_capture.py b/comfy_research/tests/test_train_run_capture.py new file mode 100644 index 0000000..67bbbbb --- /dev/null +++ b/comfy_research/tests/test_train_run_capture.py @@ -0,0 +1,44 @@ +# comfy_research/tests/test_train_run_capture.py +from __future__ import annotations + +import json + +from fastapi.testclient import TestClient + +from comfy_research.main import app +from comfy_research.engine.runs import run_index, run_store +from comfy_research.engine.runs.run_index import RunQuery +from comfy_research.tests.train_test_fixtures import minimal_cpu_train_request + + +def _events(text: str) -> list[dict]: + return [json.loads(line) for line in text.splitlines() if line.strip()] + + +def test_post_train_registers_and_persists_run(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + body = {**minimal_cpu_train_request(), "run_origin": "agent", "run_group_id": "g1"} + response = TestClient(app).post("/api/train", json=body) + assert response.status_code == 200 + events = _events(response.text) + assert events[0]["type"] == "run_registered" + run_id = events[0]["run_id"] + assert any(e["type"] == "complete" for e in events) + + rec = run_store.read_run_record(run_id) + assert rec.status == "completed" + assert rec.origin == "agent" and rec.group_id == "g1" + assert rec.graph.nodes # config snapshot present + for node in rec.graph.nodes: + assert "memoryCheckpoint_b64" not in (node.data or {}) + res = run_store.read_results(run_id) + assert len(res["loss_history"]) == 4 and "checkpoint_b64" not in res + rows, _ = run_index.query_runs(RunQuery(ids=[run_id])) + assert rows[0]["status"] == "completed" and rows[0]["hyperparams"] + + +def test_post_train_default_origin_human(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + response = TestClient(app).post("/api/train", json=minimal_cpu_train_request()) + run_id = _events(response.text)[0]["run_id"] + assert run_store.read_run_record(run_id).origin == "human" diff --git a/docs/en/reference/data-contracts.md b/docs/en/reference/data-contracts.md index fa3f01f..78bbd15 100644 --- a/docs/en/reference/data-contracts.md +++ b/docs/en/reference/data-contracts.md @@ -66,6 +66,115 @@ limit is exceeded. Template IDs must be non-empty and cannot contain slash, backslash, or a leading dot when used as canonical filenames. +## Run store (`data/runs/`) + +Each training run started through `POST /api/train` or `POST /api/runs` gets +its own directory, keyed by `run_id`: + +``` +data/runs/ + {run_id}/ + run.json # RunRecord: metadata, graph config snapshot, hyperparams + metrics.ndjson # append-only per-step metric rows + results.json # terminal snapshot (complete/paused payload, or last + # known snapshot on abort/failure/crash) + config.json # GC configuration (see the Runs API reference) + index.db # rebuildable read index (see below) +``` + +`run.json` is written atomically (`write_run_record`) on every status +transition. `metrics.ndjson` is append-only: each line is one delta row +(`{"idx", "loss", ...}`), covering only the metric indices not already +written, so appends stay O(1) instead of re-writing the whole cumulative +history on every event. `results.json` is written once, at the run's terminal +event. + +### Metrics authority rule + +`results.json` wins over `metrics.ndjson` whenever it is present: +`load_series()` returns `results.json`'s content if the file exists and +parses, otherwise it rebuilds the series (`loss_history`, +`test_loss_history`, `reg_loss_history`, `step_ticks`, `epoch_ticks`) from +`metrics.ndjson`. `GET /api/runs/{run_id}/metrics` reports which source won +as `"source": "results" | "ndjson"`. This is the single rule used everywhere +a run's metrics are read: the API, index rebuild, and stale-run +reconciliation all call the same `load_series()` function, so they never +disagree about which file is authoritative. + +### `index.db` is rebuildable, never authoritative + +`data/runs/index.db` is a SQLite index over the fields needed to list, +filter, and sort runs quickly. It is derived entirely from the `run.json` and +metrics files in each run directory, and `rebuild_index()` in +`comfy_research/engine/runs/run_index.py` can fully regenerate it at any time +by replaying every `data/runs/run-*/run.json`. A `run.json` that fails to +parse is not skipped during rebuild: it is indexed with a synthetic +`unreadable` status so it stays visible (and deletable) instead of +disappearing from listings. + +`rebuild_index()` is wired into the server startup lifespan +(`comfy_research/main.py`), which detects two recovery cases and rebuilds +automatically before serving traffic: a missing or corrupt `index.db` (a +`sqlite3.DatabaseError` opening it), and an index whose row count is lower +than the number of `run-*/run.json` directories actually on disk. It +also runs on demand from `scripts/rebuild_run_index.py`. What runs +automatically after every async-submitted run finishes, in addition to +startup, is `reconcile_stale_running()` (marks stuck `running`/`queued` rows +`crashed`) and the retention GC; both of those read and update existing index +rows rather than rebuilding the whole file. Never hand-edit `index.db` +directly either way: any edit not reflected in the corresponding `run.json` +is discarded the next time the index is rebuilt, and in the meantime it only +leaves the index and the files it's supposed to mirror disagreeing. + +### What is never persisted + +The run store keeps only training-loop metrics and lightweight metadata. The +following are stripped before anything is written to `data/runs/`: + +- **Checkpoints** (`checkpoint_b64` is stripped from both the graph + snapshot in `run.json` (`RUN_RESULT_DATA_KEYS`) and the terminal payload + in `results.json` (`_RESULT_STRIP_KEYS`); it never appears in + `metrics.ndjson` because that file only ever receives per-step metric + rows, not checkpoint data). `memoryCheckpoint_b64`, the `model_checkpoint` + node's separate memory-checkpoint field used on both local and remote + runs, is a different key, stripped only from the `run.json` graph + snapshot (also via `RUN_RESULT_DATA_KEYS`); it is not part of + `_RESULT_STRIP_KEYS`, but it also never appears in the trainer's terminal + event payload in the first place, so it does not reach `results.json` + either way. +- **Rendered images**: `plot_png_base64` and related preview/plot PNG + payloads. +- **Embedding and attention histories**: `observable_embedding_histories`, + `observable_attention_slice_histories`, and per-node + `embeddingHistory`/`attentionMapFrames` UI blobs. +- Other UI-only result fields the frontend stashes on node `data` (loss/test + histories, tick arrays, run summaries, last-error text; the full list is + `RUN_RESULT_DATA_KEYS` in `comfy_research/schemas/run_record.py`) are + stripped from the graph snapshot stored in `run.json` (`strip_result_data`) + and from `results.json` (`_RESULT_STRIP_KEYS` in + `comfy_research/engine/runs/run_store.py`). + +`run.json` stores a config-only graph snapshot: node `data` with these +result/UI keys removed, so the run's inputs are reproducible without +carrying the run's outputs back into the graph document. + +### `paused` is terminal in the run store + +`paused` is one of the run store's terminal statuses (`TERMINAL_STATUSES` +includes it alongside `completed`, `failed`, `aborted`, and `crashed`): a +paused run will not accept further metric events and is eligible for +deletion like any other finished run. Resuming a paused run does not reopen +it: the client submits a **new** run whose `TrainRequest.resume` carries the +checkpoint state from the `paused` event, and whose `run_parent_id` is set to +the paused run's `run_id`. The new run's `RunRecord.parent_id` records that +link, so a resume chain can be traced back through `parent_id` without the +store ever mutating a terminal run in place. + +Source: `comfy_research/engine/runs/run_store.py`, +`comfy_research/engine/runs/run_index.py`, +`comfy_research/schemas/run_record.py`. Route contracts: +[Runs API](runs-api.md). + ## Export tiers | Tier | Filtering contract | diff --git a/docs/en/reference/index.md b/docs/en/reference/index.md index c6e00e3..420df0f 100644 --- a/docs/en/reference/index.md +++ b/docs/en/reference/index.md @@ -44,6 +44,13 @@ Stable workflow routes and newline-delimited training event contracts. Graph, workspace, and saved-library document shapes and versions. ::: +:::{grid-item-card} Runs API +:link: runs-api +:link-type: doc +:class-card: cr-link-card +Async run submission, lifecycle, metrics, and retention. +::: + :::{grid-item-card} Node contracts :link: node-contracts :link-type: doc @@ -66,6 +73,7 @@ Stable documentation scope, experimental areas, and development-version policy. Application Training API Data contracts +Runs API Node contracts Support status ``` diff --git a/docs/en/reference/runs-api.md b/docs/en/reference/runs-api.md new file mode 100644 index 0000000..81be2e1 --- /dev/null +++ b/docs/en/reference/runs-api.md @@ -0,0 +1,202 @@ +--- +doc_type: reference +doc_status: stable-core +--- + +:::{div} cr-eyebrow +Reference +::: + +# Runs API + +The run store persists every training run started through `POST /api/train` +or `POST /api/runs` under `data/runs/{run_id}/`. The routes below query and +manage that store. Files on disk are the source of truth; `data/runs/index.db` +is a rebuildable read index (see +[Data contracts](data-contracts.md) for the on-disk +layout). + +CRL (curriculum-reinforcement) trainer runs, submitted through streaming +`POST /api/train` for a `crl_trainer` node, are not captured by the run store +at all: no `run.json` is written and no run ID is returned, so these runs +never appear in `GET /api/runs`, `GET /api/runs/{run_id}`, or the index. + +## Async submit contract + +`POST /api/runs` accepts the same `TrainRequest` body as `POST /api/train` +but does not stream. It returns immediately with `202 Accepted` and the run +executes detached from the HTTP request: + +| Behavior | Contract | +| --- | --- | +| Response | `202` with `{"run_id", "status"}` (`status` is `"queued"`) | +| `Idempotency-Key` header | Optional. Repeating the same key while the first submission is in flight (or after it has been recorded) returns the same run instead of starting a second one. | +| Remote GPU trainer | `400` with `{"detail": {"code": "remote_not_supported", "detail": "..."}}` (see the error envelope section below). Async submit only runs locally; use the streaming `POST /api/train` for a trainer node configured with `computeDevice: "cuda"` and `remoteGpu: true`. | +| Serialization | Runs submitted for the same `trainer_node_id` execute one at a time, FIFO, because the underlying trainer control registry holds one slot per trainer ID. Runs for different trainer IDs execute concurrently up to `worker_slots`. | + +## Routes + +| Method and path | Contract | +| --- | --- | +| `POST /api/runs` | Submit a run for async execution. See above. | +| `GET /api/runs` | List runs. Query params below. Returns `{"runs": [...], "next_cursor"}`. | +| `GET /api/runs/groups` | Summarize runs by `group_id`: per-group status counts, best test loss, best final loss. | +| `GET /api/runs/{run_id}` | Full run record plus a `summary` (`final_loss`, `final_test_loss`, `best_test_loss`, `steps_completed`) computed from the metrics authority. `404 run_not_found` if the run does not exist. | +| `GET /api/runs/{run_id}/metrics` | The metrics series for one run. See below. `404 run_not_found` if the run does not exist, and also if it is `unreadable` (unlike `GET /api/runs/{run_id}`, this route does not special-case unreadable runs). | +| `POST /api/runs/{run_id}/abort` | Cooperatively abort a run that was submitted through `POST /api/runs` (the async worker pool). Returns `{"ok": true}` if a cancellation was issued, `{"ok": false}` if the worker pool has no active or queued entry for this ID (either because the run is already terminal, or because it was never submitted through `POST /api/runs` in the first place, e.g. it is a streaming `POST /api/train` run; the worker pool doesn't track those). `404 run_not_found` if the run does not exist at all, and also if it is `unreadable`. To abort a run started through streaming `POST /api/train`, use `POST /api/train/control` with its `trainer_node_id` instead. | +| `DELETE /api/runs/{run_id}` | Delete one run's directory and index row. `409 run_active` if the run is not in a terminal or `unreadable` state (abort it first). `404 run_not_found` if the run does not exist. | +| `DELETE /api/runs` | Bulk delete. Requires at least one filter; see below. Returns `{"deleted": }`. | + +### List query params (`GET /api/runs`, and filters for `DELETE /api/runs`) + +| Param | Meaning | +| --- | --- | +| `status` | Exact match on run status. | +| `origin` | Exact match on `human`, `agent`, or `sweep`. | +| `group_id` | Exact match on group ID. | +| `since` | Unix time in milliseconds; only runs created at or after this time. | +| `ids` | Comma-separated list of run IDs. | +| `hyperparam.{node_id}.{field}` | Filter on a flattened hyperparameter value, e.g. `hyperparam.optimizer.learningRate=0.01`. Compared as text. | +| `order_by` | One of `created_at`, `finished_at`, `final_loss`, `final_test_loss`, `best_test_loss`, `steps_completed`, `duration_seconds`. Prefix with `-` for descending (default: `-created_at`). An unrecognized column falls back to `-created_at`. | +| `limit` | Page size, default `100`, clamped to `1..500`. | +| `cursor` | Opaque cursor from a previous page's `next_cursor`. | + +Cursor pagination is keyset-based (not offset), so it stays correct while new +runs are inserted. `next_cursor` is `null` on the last page. + +### `GET /api/runs/{run_id}/metrics` + +| Param | Meaning | +| --- | --- | +| `downsample` | If set and a series is longer than this value, stride-sample it down to roughly this many points. Response includes `"downsampled": true` when any series was reduced. | + +Response shape: `{"source": "results" | "ndjson", "data": {...}, "downsampled": bool}`. +`source` reports which store backed the response; see the authority rule in +[Data contracts](data-contracts.md). + +## Status lifecycle + +``` +queued -> running -> completed | failed | aborted | paused +``` + +| Status | Meaning | +| --- | --- | +| `queued` | Submitted, not yet started (worker pool is busy with another run on the same trainer, or the run has not been dispatched yet). | +| `running` | Executing. | +| `completed` | Finished normally (`complete` NDJSON event). | +| `failed` | Errored (`error` NDJSON event, an unhandled exception during execution, or a validation failure at submit time). | +| `aborted` | Stopped cooperatively (`POST /api/runs/{run_id}/abort`, `POST /api/train/control`, or the client disconnecting mid-stream). | +| `paused` | Stopped with resumable state. Terminal in the run store: resuming starts a **new** run whose `TrainRequest.resume` carries the checkpoint and whose `run_parent_id` points back at this run. See [Data contracts](data-contracts.md). | +| `crashed` | Reconciliation status. Any run still `running` or `queued` at server startup is marked `crashed` with `finished_at` set (nothing can legitimately still be executing in a fresh process). During normal operation, a run that stays `running` or `queued` past a heartbeat timeout (no progress event for 60 seconds) with no terminal event ever recorded, e.g. the server process died mid-run, is also marked `crashed`. Both cases run through `reconcile_stale_running()`; it is not periodic and it is not the same operation as rebuilding `index.db`. | +| `unreadable` | Not a stored status value; a synthetic state returned by the API/index when `run.json` exists on disk but fails to parse. Unreadable runs are deletable (`DELETE /api/runs/{run_id}` and bulk delete both treat `unreadable` as eligible) so they can be cleared without hand-editing the store. | + +`completed`, `failed`, `aborted`, `paused`, and `crashed` are terminal: the +run store will not accept further metric events for them. + +## Error envelope + +Every error response from `/api/runs*` is a FastAPI `HTTPException`, so the +JSON response body nests the structured error under the top-level `detail` +key that FastAPI always wraps `HTTPException.detail` in: the response is +`{"detail": {"code": ..., "detail": ...}}`, not a flat object: + +```json +{"detail": {"code": "run_not_found", "detail": "No run 'run-xxxxxxxxxxxx'."}} +``` + +| `code` | Status | When | +| --- | --- | --- | +| `run_not_found` | 404 | The run ID does not exist. | +| `invalid_run_id` | 400 | The `run_id` path parameter is not a well-formed run ID (e.g. it contains a path separator). | +| `invalid_cursor` | 400 | The `cursor` query parameter on `GET /api/runs` could not be parsed. | +| `invalid_query_param` | 400 | `since` is not a valid number, or `limit` is not a valid integer. | +| `remote_not_supported` | 400 | `POST /api/runs` was called for a trainer node configured for remote GPU. | +| `run_active` | 409 | `DELETE /api/runs/{run_id}` was called on a run that is not terminal or `unreadable`. | +| `filter_required` | 400 | `DELETE /api/runs` was called with no filter at all. | + +## Bulk delete filter requirement + +`DELETE /api/runs` refuses to run with zero filters: it always requires at +least one of `status`, `origin`, `group_id`, `since`, `ids`, or a +`hyperparam.*` filter, so an empty query can never wipe the entire store. +Given filters, it paginates through every matching row, deletes those whose +status is terminal or `unreadable`, and skips any active runs the filter also +matched (it does not abort them). + +## Garbage collection + +A retention sweep for `origin=agent` and `origin=sweep` runs at server +startup and again after every async-submitted run finishes execution. +Configuration is read from +`data/runs/config.json`; any keys not present fall back to defaults: + +| Key | Default | Meaning | +| --- | --- | --- | +| `max_runs_agent` | `2000` | Keep at most this many `agent`-origin runs (newest first); older terminal runs past this cap are eligible for pruning. | +| `max_age_days_agent` | `None` (disabled) | If set, also prune terminal `agent`-origin runs older than this many days, even within the cap. | +| `max_runs_sweep` | `2000` | Same cap and grace/terminal rules as `max_runs_agent`, applied to `sweep`-origin runs (the highest-volume producer, since each sweep or coordinate-descent session writes one run per evaluated point). | +| `worker_slots` | `2` | Number of runs the async worker pool can execute concurrently (across distinct `trainer_node_id`s). Read once at process start. | + +Only runs that are terminal, have a recorded `finished_at`, and finished more +than 10 minutes ago are eligible for pruning; this grace period keeps a +just-finished run visible before GC can remove it. GC never touches +`human` origin runs. + +## Known limitations + +The `train_control` registry that backs pause and abort signals holds one +slot per `trainer_node_id`, not one per run. Avoid running the same graph +concurrently through both `POST /api/runs` (the async worker pool) and a +streaming `POST /api/train` request: if two runs share a `trainer_node_id` +while both are active, a pause or abort signal is routed by trainer ID and +can land on the wrong run. + +## curl example + +Submit a minimal CPU run, poll it, then fetch its metrics. The body shape +below is the same one used by `minimal_cpu_train_request` in the test suite +(`comfy_research/tests/train_test_fixtures.py`): a two-layer MLP trained for +three steps on a tiny synthetic linear dataset. + +```bash +# 1. Submit +curl -sS -X POST http://127.0.0.1:8000/api/runs \ + -H 'Content-Type: application/json' \ + -H 'Idempotency-Key: demo-run-1' \ + -d '{ + "trainer_node_id": "trainer", + "nodes": [ + {"id": "dataset", "type": "linear_dataset", "data": { + "inputDim": 2, "outputDim": 1, "trainSize": 8, "testSize": 4, + "noiseLevel": 0, "seed": 0, "samplingMode": "fixed"}}, + {"id": "model", "type": "mlp_model", "data": { + "inputDim": 2, "outputDim": 1, "depth": 1, "width": 4, + "activation": "relu", "seed": 0}}, + {"id": "optimizer", "type": "adam_optimizer", "data": {"learningRate": 0.01}}, + {"id": "loss", "type": "mse_loss", "data": {}}, + {"id": "trainer", "type": "trainer", "data": { + "trainingSteps": 3, "logFrequency": 1, "batchSize": -1, + "computeDevice": "cpu"}} + ], + "edges": [ + {"id": "dataset-trainer", "source": "dataset", "target": "trainer", + "sourceHandle": "dataset", "targetHandle": "dataset"}, + {"id": "model-trainer", "source": "model", "target": "trainer", + "sourceHandle": "model", "targetHandle": "model"}, + {"id": "optimizer-trainer", "source": "optimizer", "target": "trainer", + "sourceHandle": "optimizer", "targetHandle": "optimizer"}, + {"id": "loss-trainer", "source": "loss", "target": "trainer", + "sourceHandle": "loss", "targetHandle": "loss"} + ] + }' +# => {"run_id": "run-xxxxxxxxxxxx", "status": "queued"} + +# 2. Poll +curl -sS http://127.0.0.1:8000/api/runs/run-xxxxxxxxxxxx +# => {"run_id": "...", "status": "completed", ..., "summary": {"final_loss": ..., ...}} + +# 3. Fetch metrics +curl -sS http://127.0.0.1:8000/api/runs/run-xxxxxxxxxxxx/metrics +# => {"source": "results", "data": {"loss_history": [...], ...}, "downsampled": false} +``` diff --git a/docs/en/reference/training-api.md b/docs/en/reference/training-api.md index b5a9bdc..9ebad98 100644 --- a/docs/en/reference/training-api.md +++ b/docs/en/reference/training-api.md @@ -42,6 +42,20 @@ the supported stable workflow surface documented here. state, and optional `hessian_oversized_policy` (`skip` or `force`). Nodes and edges are validated using the graph schema before the run is prepared. +`TrainRequest` also carries the run store's provenance fields, all optional: + +| Field | Type | Meaning | +| --- | --- | --- | +| `run_origin` | `human`, `agent`, or `sweep` | Default `human`. Determines which runs the agent-origin GC sweep and `origin` query filters see; see [Runs API](runs-api.md). | +| `run_group_id` | string or `null` | Groups related runs (e.g. a sweep's inner runs) for `GET /api/runs/groups` and the `group_id` query filter. | +| `run_parent_id` | string or `null` | Set when this run resumes a `paused` run; becomes the new `RunRecord.parent_id`, linking a resume chain back to the run it resumed. See [Data contracts](data-contracts.md). | + +These fields are only meaningful when the run is captured into the run +store: every `POST /api/train` request (local and remote) and every +`POST /api/runs` submission builds a `RunRecord` from them, except a +`POST /api/train` request for a `crl_trainer` node, which is never captured. +See [Runs API](runs-api.md). + ## NDJSON response `POST /api/train` returns `application/x-ndjson`. Each line is a complete JSON @@ -50,6 +64,7 @@ response as one JSON document. | Event type | Meaning and stable fields | | --- | --- | +| `run_registered` | First event of the stream (local and remote), with `run_id`. The run has been persisted to the run store (`data/runs/{run_id}/`) and can be queried through the [Runs API](runs-api.md) while streaming continues. Not emitted for CRL trainer runs, which are not captured into the run store. | | `progress` | Run position with `step` and `total` | | `phase` | Remote bootstrap or execution phase with `phase` and `message` | | `complete` | Terminal success with checkpoint, loss histories, ticks, visualization targets, and Observable updates | @@ -60,10 +75,11 @@ response as one JSON document. Non-finite numeric values are converted to JSON `null` before encoding so each line remains valid RFC 8259 JSON. -For a normal local run the documented sequence begins with one or more -`progress` events and ends in `complete`, `paused`, or `aborted`. Remote runs -can emit `phase` before training and can terminate with `error` if the SSH -process fails without another terminal event. +For a normal local run the documented sequence begins with `run_registered`, +then one or more `progress` events, and ends in `complete`, `paused`, or +`aborted`. Remote runs emit `run_registered` after bootstrap and validation +succeed, can emit `phase` before training, and can terminate with `error` if +the SSH process fails without another terminal event. ## Sweep routes diff --git a/docs/locales/zh_CN/LC_MESSAGES/examples/index.po b/docs/locales/zh_CN/LC_MESSAGES/examples/index.po index 548db7b..3badda2 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/examples/index.po +++ b/docs/locales/zh_CN/LC_MESSAGES/examples/index.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 15:36+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: zh_CN\n" @@ -29,17 +29,19 @@ msgstr "复现" #: ../../en/examples/index.md:13 msgid "" -"Classic learning-mechanics and physics-of-AI phenomena, reproduced as runnable " -"graphs. Each article states its experimental boundary, method, result, and " -"limitations; visual similarity alone is not presented as numeric replication." +"Classic learning-mechanics and physics-of-AI phenomena, reproduced as " +"runnable graphs. Each article states its experimental boundary, method, " +"result, and limitations; visual similarity alone is not presented as " +"numeric replication." msgstr "以可运行图复现经典学习机制和 AI 物理学现象。每篇文章都说明实验边界、方法、结果和局限性;仅凭视觉相似性不构成数值复现。" #: ../../en/examples/index.md:19 msgid "" -"**Claim legend:** **PHENOMENON** reproduces the reported qualitative behavior; " -"**PAPER-FAITHFUL** follows the original experimental setup closely enough for that " -"stronger claim. The current collection contains sixteen phenomenon reproductions. Author" -" attribution appears inside each article." +"**Claim legend:** **PHENOMENON** reproduces the reported qualitative " +"behavior; **PAPER-FAITHFUL** follows the original experimental setup " +"closely enough for that stronger claim. The current collection contains " +"sixteen phenomenon reproductions. Author attribution appears inside each " +"article." msgstr "" "**结论图例:** **PHENOMENON** 复现所报告的定性行为;**PAPER-FAITHFUL** " "足够严格地遵循原始实验设置,从而支持这一更强结论。当前集合包含十六个现象复现,作者署名位于各文章内部。" @@ -54,306 +56,351 @@ msgid "" "optimization." msgstr "学习率、批量大小、噪声与曲率如何在优化过程中相互作用。" -#: ../../en/examples/index.md:34 +#: ../../en/examples/index.md:35 msgid "Cyclic Batch Size vs. Cyclic Learning Rate" msgstr "循环批量大小与循环学习率" -#: ../../en/examples/index.md:39 ../../en/examples/index.md:60 -#: ../../en/examples/index.md:81 ../../en/examples/index.md:112 -#: ../../en/examples/index.md:133 ../../en/examples/index.md:154 -#: ../../en/examples/index.md:175 +#: ../../en/examples/index.md:40 ../../en/examples/index.md:61 +#: ../../en/examples/index.md:82 ../../en/examples/index.md:103 +#: ../../en/examples/index.md:134 ../../en/examples/index.md:155 +#: ../../en/examples/index.md:176 ../../en/examples/index.md:197 +#: ../../en/examples/index.md:229 ../../en/examples/index.md:250 +#: ../../en/examples/index.md:272 ../../en/examples/index.md:304 +#: ../../en/examples/index.md:325 ../../en/examples/index.md:357 +#: ../../en/examples/index.md:378 ../../en/examples/index.md:409 #, python-brace-format msgid "{bdg-secondary}`PHENOMENON`" msgstr "{bdg-secondary}`PHENOMENON`" -#: ../../en/examples/index.md:42 -msgid "Matched cyclic noise-scale schedules produce similar five-seed training dynamics." +#: ../../en/examples/index.md:43 +msgid "" +"Matched cyclic noise-scale schedules produce similar five-seed training " +"dynamics." msgstr "相匹配的循环噪声尺度调度会产生相似的五种子训练动态。" -#: ../../en/examples/index.md:47 +#: ../../en/examples/index.md:48 msgid "`noise-scale` `optimization`" msgstr "`noise-scale` `optimization`" -#: ../../en/examples/index.md:51 +#: ../../en/examples/index.md:52 msgid "Template: `repro-jastrzbski-fig1-vgg11`" msgstr "模板:`repro-jastrzbski-fig1-vgg11`" -#: ../../en/examples/index.md:55 +#: ../../en/examples/index.md:56 msgid "Edge of Stability on a Small CPU MLP" msgstr "小型 CPU MLP 上的稳定性边缘" -#: ../../en/examples/index.md:63 +#: ../../en/examples/index.md:64 msgid "" -"The top Hessian eigenvalue reaches and fluctuates around $2 / \\eta$ while full-batch" -" loss decreases non-monotonically." +"The top Hessian eigenvalue reaches and fluctuates around $2 / \\eta$ " +"while full-batch loss decreases non-monotonically." msgstr "最大 Hessian 特征值到达并围绕 $2 / \\eta$ 波动,而全批量损失以非单调方式下降。" -#: ../../en/examples/index.md:68 +#: ../../en/examples/index.md:69 ../../en/examples/index.md:90 msgid "`sharpness` `full-batch`" msgstr "`sharpness` `full-batch`" -#: ../../en/examples/index.md:72 +#: ../../en/examples/index.md:73 msgid "Template: `edge-of-stability-cpu`" msgstr "模板:`edge-of-stability-cpu`" -#: ../../en/examples/index.md:76 -msgid "Rank Collapse in a Linear Bottleneck" -msgstr "线性瓶颈中的秩坍塌" +#: ../../en/examples/index.md:77 +msgid "Edge of Stability (EOS)" +msgstr "Edge of Stability(EoS)" -#: ../../en/examples/index.md:84 +#: ../../en/examples/index.md:85 msgid "" -"Skewed class frequency and a d=2 bottleneck produce warmup, entropy expansion, then " -"late-window RankMe compression." -msgstr "偏斜的类别频率与 d=2 瓶颈会产生预热、熵扩张,随后出现后期窗口 RankMe 压缩。" - -#: ../../en/examples/index.md:89 -msgid "`rank-collapse` `representation`" -msgstr "`rank-collapse` `representation`" +"Leading curvature approaches 2 / eta while full-batch loss makes non-" +"monotonic net progress." +msgstr "主导曲率接近 2 / eta,而全批量损失以非单调方式取得总体进展。" -#: ../../en/examples/index.md:93 -msgid "Template: `repro-rank-collapse-figure5-linear-ce`" -msgstr "模板:`repro-rank-collapse-figure5-linear-ce`" +#: ../../en/examples/index.md:94 +msgid "Template: `97ff01a8-1724-43ec-8c38-fdb62bbe5faf`" +msgstr "模板:`97ff01a8-1724-43ec-8c38-fdb62bbe5faf`" #: ../../en/examples/index.md:98 -msgid "Learning Phases and Feature Formation" -msgstr "学习阶段与特征形成" - -#: ../../en/examples/index.md:100 -msgid "" -"How networks enter distinct regimes, learn dominant modes, and " -"specialize." -msgstr "网络如何进入不同机制、学习主导模式并发生专化。" - -#: ../../en/examples/index.md:107 msgid "Small-Batch vs. Large-Batch Training" msgstr "小批量与大批量训练" -#: ../../en/examples/index.md:115 +#: ../../en/examples/index.md:106 msgid "" -"A generalization gap appears alongside greater path-wise sharpness near the large-" -"batch solution." +"A generalization gap appears alongside greater path-wise sharpness near " +"the large-batch solution." msgstr "在大批量解附近,更大的路径尖锐度伴随泛化差距出现。" -#: ../../en/examples/index.md:120 +#: ../../en/examples/index.md:111 msgid "`generalization` `sharpness`" msgstr "`generalization` `sharpness`" -#: ../../en/examples/index.md:124 +#: ../../en/examples/index.md:115 msgid "Template: `repro-keskar-fig23-sb-lb`" msgstr "模板:`repro-keskar-fig23-sb-lb`" -#: ../../en/examples/index.md:128 -msgid "Diffusion Reproducibility Across Data Scale" -msgstr "跨数据规模的扩散可复现性" - -#: ../../en/examples/index.md:136 -msgid "" -"Same-noise diffusion outputs progress from unstable memorization to fine-grained, " -"less-train-near agreement as CIFAR-10 data scale increases." -msgstr "随着 CIFAR-10 数据规模增加,同噪声扩散输出从不稳定记忆转向细粒度、且更不接近训练样本的一致性。" - -#: ../../en/examples/index.md:141 -msgid "`diffusion` `reproducibility` `generalization`" -msgstr "`diffusion` `reproducibility` `generalization`" - -#: ../../en/examples/index.md:145 -msgid "Template: `repro-diffusion-same-init-different-seed`" -msgstr "模板:`repro-diffusion-same-init-different-seed`" - -#: ../../en/examples/index.md:149 -msgid "Linear and Curve Mode Connectivity" -msgstr "线性与曲线模态连通性" - -#: ../../en/examples/index.md:157 -msgid "" -"Direct interpolation becomes less favorable during training, while a quadratic Bezier" -" path remains low-loss between the final same-init endpoints." -msgstr "训练过程中直接插值愈发不利,而二次 Bezier 路径在最终同初始化端点之间仍保持低损失。" - -#: ../../en/examples/index.md:162 -msgid "`loss-landscape` `mode-connectivity` `CIFAR-10`" -msgstr "`loss-landscape` `mode-connectivity` `CIFAR-10`" - -#: ../../en/examples/index.md:166 -msgid "Template: `repro-linear-mode-connectivity-cifar10`" -msgstr "模板:`repro-linear-mode-connectivity-cifar10`" - -#: ../../en/examples/index.md:170 -msgid "Rank Collapse on Real Text (TinyShakespeare)" -msgstr "真实文本上的秩坍塌(TinyShakespeare)" - -#: ../../en/examples/index.md:178 -msgid "" -"A small causal Transformer trained on real TinyShakespeare text shows the same warmup" -" → expansion → compression RankMe signature as intermediate pretraining checkpoints." -msgstr "在真实 TinyShakespeare 文本上训练的小型因果 Transformer,呈现出与中间预训练检查点相同的预热 → 扩张 → 压缩 RankMe 特征。" - -#: ../../en/examples/index.md:184 -msgid "`rank-collapse` `language-model`" -msgstr "`rank-collapse` `language-model`" +#: ../../en/examples/index.md:120 +msgid "Learning Phases and Feature Formation" +msgstr "学习阶段与特征形成" -#: ../../en/examples/index.md:188 -msgid "Template: `repro-rank-collapse-tinyshakespeare-pretraining`" -msgstr "模板:`repro-rank-collapse-tinyshakespeare-pretraining`" +#: ../../en/examples/index.md:122 +msgid "How networks enter distinct regimes, learn dominant modes, and specialize." +msgstr "网络如何进入不同机制、学习主导模式并发生专化。" -#: ../../en/examples/index.md +#: ../../en/examples/index.md:129 msgid "Lazy vs. Rich Training Regimes" msgstr "Lazy vs. Rich 训练机制" -msgid "Output scaling controls whether a wide ReLU network learns moving features or stays close to its initialization." +#: ../../en/examples/index.md:137 +msgid "" +"Output scaling controls whether a wide ReLU network learns moving " +"features or stays close to its initialization." msgstr "输出缩放决定宽 ReLU 网络是学习会移动的特征,还是保持在初始化附近。" +#: ../../en/examples/index.md:142 msgid "`feature-learning` `scaling`" msgstr "`特征学习` `缩放`" +#: ../../en/examples/index.md:146 +msgid "Template: `d2e8cdd7-d14c-42c3-94dc-ba2b419c07f9`" +msgstr "模板:`d2e8cdd7-d14c-42c3-94dc-ba2b419c07f9`" + +#: ../../en/examples/index.md:150 msgid "Staggered Singular-Value Dynamics" msgstr "Staggered Singular-Value 动力学" -msgid "Deep linear training learns the strongest effective-map singular modes before weaker ones." +#: ../../en/examples/index.md:158 +msgid "" +"Deep linear training learns the strongest effective-map singular modes " +"before weaker ones." msgstr "深度线性训练先学习有效映射中最强的奇异模式,再学习较弱模式。" +#: ../../en/examples/index.md:163 msgid "`deep-linear` `singular-values`" msgstr "`深度线性` `奇异值`" -msgid "Edge of Stability (EOS)" -msgstr "Edge of Stability(EoS)" - -msgid "Leading curvature approaches 2 / eta while full-batch loss makes non-monotonic net progress." -msgstr "主导曲率接近 2 / eta,而全批量损失以非单调方式取得总体进展。" - -msgid "`sharpness` `full-batch`" -msgstr "`尖锐度` `全批量`" +#: ../../en/examples/index.md:167 +msgid "Template: `a04f21b3-e31c-4ba3-b8b9-d3af752f77d4`" +msgstr "模板:`a04f21b3-e31c-4ba3-b8b9-d3af752f77d4`" -msgid "In-Context Associative Recall" -msgstr "In-Context Associative Recall(上下文联想回忆)" - -msgid "A causal Transformer retrieves a paired value from a synthetic context and exposes the attention patterns used during recall." -msgstr "因果 Transformer 从合成上下文中检索配对值,并展示回忆时使用的注意力模式。" - -msgid "`in-context-learning` `attention`" -msgstr "`上下文学习` `注意力`" - -#: ../../docs/en/examples/index.md:97 +#: ../../en/examples/index.md:171 msgid "Spectral Bias: Low Frequencies Are Learned First" msgstr "频谱偏置:低频成分优先学习" -#: ../../docs/en/examples/index.md:105 +#: ../../en/examples/index.md:179 msgid "" "Ten equal-amplitude Fourier components reach their target amplitudes in a" " consistent low-to-high frequency order." msgstr "十个等幅 Fourier 分量以一致的从低频到高频顺序达到目标幅值。" -#: ../../docs/en/examples/index.md:110 +#: ../../en/examples/index.md:184 msgid "`spectral-bias` `fourier`" msgstr "`spectral-bias` `fourier`" -#: ../../docs/en/examples/index.md:114 +#: ../../en/examples/index.md:188 msgid "Template: `repro-spectral-bias-fig1a`" msgstr "模板:`repro-spectral-bias-fig1a`" -#: ../../docs/en/examples/index.md:213 +#: ../../en/examples/index.md:192 +msgid "Exact Solution for On-Line Learning in Multilayer Neural Networks" +msgstr "多层神经网络在线学习的精确解" + +#: ../../en/examples/index.md:200 +msgid "" +"On-line gradient-descent learning exhibits a plateau in generalization " +"error before specialization eliminates the redundant student node." +msgstr "在线梯度下降学习在泛化误差中展现出平台期,随后专化消除冗余的学生节点。" + +#: ../../en/examples/index.md:205 +msgid "`teacher-student` `generalization`" +msgstr "`师生网络` `泛化`" + +#: ../../en/examples/index.md:209 +msgid "Template: `e399fd7d-e107-44d0-94b6-7e2159392253`" +msgstr "模板:`e399fd7d-e107-44d0-94b6-7e2159392253`" + +#: ../../en/examples/index.md:214 +msgid "Representation Compression and Information Flow" +msgstr "表征压缩与信息流" + +#: ../../en/examples/index.md:216 +msgid "" +"How internal representations expand, compress, and retain task-relevant " +"information." +msgstr "内部表征如何扩张、压缩并保留与任务相关的信息。" + +#: ../../en/examples/index.md:224 +msgid "Rank Collapse in a Linear Bottleneck" +msgstr "线性瓶颈中的秩坍塌" + +#: ../../en/examples/index.md:232 +msgid "" +"Skewed class frequency and a d=2 bottleneck produce warmup, entropy " +"expansion, then late-window RankMe compression." +msgstr "偏斜的类别频率与 d=2 瓶颈会产生预热、熵扩张,随后出现后期窗口 RankMe 压缩。" + +#: ../../en/examples/index.md:237 +msgid "`rank-collapse` `representation`" +msgstr "`rank-collapse` `representation`" + +#: ../../en/examples/index.md:241 +msgid "Template: `repro-rank-collapse-figure5-linear-ce`" +msgstr "模板:`repro-rank-collapse-figure5-linear-ce`" + +#: ../../en/examples/index.md:245 +msgid "Rank Collapse on Real Text (TinyShakespeare)" +msgstr "真实文本上的秩坍塌(TinyShakespeare)" + +#: ../../en/examples/index.md:253 +msgid "" +"A small causal Transformer trained on real TinyShakespeare text shows the" +" same warmup → expansion → compression RankMe signature as intermediate " +"pretraining checkpoints." +msgstr "" +"在真实 TinyShakespeare 文本上训练的小型因果 Transformer,呈现出与中间预训练检查点相同的预热 → 扩张 → 压缩 " +"RankMe 特征。" + +#: ../../en/examples/index.md:259 +msgid "`rank-collapse` `language-model`" +msgstr "`rank-collapse` `language-model`" + +#: ../../en/examples/index.md:263 +msgid "Template: `repro-rank-collapse-tinyshakespeare-pretraining`" +msgstr "模板:`repro-rank-collapse-tinyshakespeare-pretraining`" + +#: ../../en/examples/index.md:267 msgid "Information Bottleneck Dynamics Across Training-Set Sizes" msgstr "不同训练集规模下的信息瓶颈动力学" -#: ../../docs/en/examples/index.md:221 +#: ../../en/examples/index.md:275 msgid "" "Information-plane trajectories show early label-information growth, later" " leftward motion, and more final label information with more training " "data." msgstr "信息平面轨迹显示标签信息早期增长、随后向左移动,并且更多训练数据对应更多最终标签信息。" -#: ../../docs/en/examples/index.md:226 +#: ../../en/examples/index.md:280 msgid "`information-bottleneck` `representation`" msgstr "`information-bottleneck` `representation`" -#: ../../docs/en/examples/index.md:230 +#: ../../en/examples/index.md:284 msgid "Template: `dafb8339-a932-4b10-b3b6-185fc53a5a4f`" msgstr "模板:`dafb8339-a932-4b10-b3b6-185fc53a5a4f`" -#: ../../docs/en/examples/index.md:234 +#: ../../en/examples/index.md:289 +msgid "Generalization, Memorization, and Reproducibility" +msgstr "泛化、记忆与可复现性" + +#: ../../en/examples/index.md:291 +msgid "" +"How models fit their data, generalize beyond it, and agree across " +"training runs." +msgstr "模型如何拟合数据、泛化到数据之外,并在不同训练运行间保持一致。" + +#: ../../en/examples/index.md:299 +msgid "Diffusion Reproducibility Across Data Scale" +msgstr "跨数据规模的扩散可复现性" + +#: ../../en/examples/index.md:307 +msgid "" +"Same-noise diffusion outputs progress from unstable memorization to fine-" +"grained, less-train-near agreement as CIFAR-10 data scale increases." +msgstr "随着 CIFAR-10 数据规模增加,同噪声扩散输出从不稳定记忆转向细粒度、且更不接近训练样本的一致性。" + +#: ../../en/examples/index.md:312 +msgid "`diffusion` `reproducibility` `generalization`" +msgstr "`diffusion` `reproducibility` `generalization`" + +#: ../../en/examples/index.md:316 +msgid "Template: `repro-diffusion-same-init-different-seed`" +msgstr "模板:`repro-diffusion-same-init-different-seed`" + +#: ../../en/examples/index.md:320 msgid "Memorizing Random Labels on CIFAR-10" msgstr "在 CIFAR-10 上记忆随机标签" -#: ../../docs/en/examples/index.md:242 +#: ../../en/examples/index.md:328 msgid "" "A Small Inception model reaches 100% training accuracy on true labels, " "fixed random labels, and three randomized-input conditions." msgstr "Small Inception 模型在真实标签、固定随机标签和三种随机化输入条件上都达到 100% 训练准确率。" -#: ../../docs/en/examples/index.md:247 +#: ../../en/examples/index.md:333 msgid "`memorization` `generalization`" msgstr "`memorization` `generalization`" -#: ../../docs/en/examples/index.md:251 +#: ../../en/examples/index.md:337 msgid "Template: `repro-random-label-memorization-fig1a`" msgstr "模板:`repro-random-label-memorization-fig1a`" -#: ../../en/examples/index.md:340 -msgid "Exact Solution for On-Line Learning in Multilayer Neural Networks" -msgstr "多层神经网络在线学习的精确解" +#: ../../en/examples/index.md:342 +msgid "Loss Geometry and Parameter Symmetry" +msgstr "损失几何与参数对称性" -#: ../../en/examples/index.md:348 +#: ../../en/examples/index.md:344 msgid "" -"On-line gradient-descent learning exhibits a plateau in generalization " -"error before specialization eliminates the redundant student node." -msgstr "" -"在线梯度下降学习在泛化误差中展现出平台期,随后专化消除冗余的学生节点。" +"How solutions relate across parameter space and how training breaks " +"geometric invariants." +msgstr "不同解在参数空间中如何关联,以及训练如何打破几何不变量。" #: ../../en/examples/index.md:352 -msgid "`teacher-student` `generalization`" -msgstr "`师生网络` `泛化`" - -#: ../../en/examples/index.md:356 -msgid "Template: `e399fd7d-e107-44d0-94b6-7e2159392253`" -msgstr "模板:`e399fd7d-e107-44d0-94b6-7e2159392253`" +msgid "Linear and Curve Mode Connectivity" +msgstr "线性与曲线模态连通性" #: ../../en/examples/index.md:360 +msgid "" +"Direct interpolation becomes less favorable during training, while a " +"quadratic Bezier path remains low-loss between the final same-init " +"endpoints." +msgstr "训练过程中直接插值愈发不利,而二次 Bezier 路径在最终同初始化端点之间仍保持低损失。" + +#: ../../en/examples/index.md:365 +msgid "`loss-landscape` `mode-connectivity` `CIFAR-10`" +msgstr "`loss-landscape` `mode-connectivity` `CIFAR-10`" + +#: ../../en/examples/index.md:369 +msgid "Template: `repro-linear-mode-connectivity-cifar10`" +msgstr "模板:`repro-linear-mode-connectivity-cifar10`" + +#: ../../en/examples/index.md:373 msgid "Neural Mechanics: Symmetry and Broken Conservation Laws" msgstr "神经力学:对称性与破缺守恒律" -#: ../../en/examples/index.md:368 +#: ../../en/examples/index.md:381 msgid "" "Finite learning rates and weight decay break the conservation laws of " "gradient flow; VGG-11 on CIFAR-10 traces the full centripetal-centrifugal" " spectrum." -msgstr "" -"有限学习率和权重衰减打破了梯度流的守恒律;VGG-11 在 CIFAR-10 上展现了" -"完整的向心-离心谱。" +msgstr "有限学习率和权重衰减打破了梯度流的守恒律;VGG-11 在 CIFAR-10 上展现了完整的向心-离心谱。" -#: ../../en/examples/index.md:372 +#: ../../en/examples/index.md:386 msgid "`symmetry` `conservation-laws`" msgstr "`对称性` `守恒律`" -#: ../../en/examples/index.md:376 +#: ../../en/examples/index.md:390 msgid "Template: `b38ae9dd-735b-46c4-973f-a850a2a55544`" msgstr "模板:`b38ae9dd-735b-46c4-973f-a850a2a55544`" -msgid "Representation Compression and Information Flow" -msgstr "表征压缩与信息流" +#: ../../en/examples/index.md:395 +msgid "In-Context Learning" +msgstr "上下文学习" -msgid "" -"How internal representations expand, compress, and retain task-relevant " -"information." -msgstr "内部表征如何扩张、压缩并保留与任务相关的信息。" +#: ../../en/examples/index.md:397 +msgid "How sequence models acquire task behavior from the current context." +msgstr "序列模型如何从当前上下文中获得任务行为。" -msgid "Generalization, Memorization, and Reproducibility" -msgstr "泛化、记忆与可复现性" +#: ../../en/examples/index.md:404 +msgid "In-Context Associative Recall" +msgstr "In-Context Associative Recall(上下文联想回忆)" +#: ../../en/examples/index.md:412 msgid "" -"How models fit their data, generalize beyond it, and agree across training " -"runs." -msgstr "模型如何拟合数据、泛化到数据之外,并在不同训练运行间保持一致。" - -msgid "Loss Geometry and Parameter Symmetry" -msgstr "损失几何与参数对称性" +"A causal Transformer retrieves a paired value from a synthetic context " +"and exposes the attention patterns used during recall." +msgstr "因果 Transformer 从合成上下文中检索配对值,并展示回忆时使用的注意力模式。" -msgid "" -"How solutions relate across parameter space and how training breaks " -"geometric invariants." -msgstr "不同解在参数空间中如何关联,以及训练如何打破几何不变量。" +#: ../../en/examples/index.md:417 +msgid "`in-context-learning` `attention`" +msgstr "`上下文学习` `注意力`" -msgid "In-Context Learning" -msgstr "上下文学习" +#: ../../en/examples/index.md:421 +msgid "Template: `5d1a2ab4-825d-4251-8a5a-d7b83b42c1d7`" +msgstr "模板:`5d1a2ab4-825d-4251-8a5a-d7b83b42c1d7`" -msgid "How sequence models acquire task behavior from the current context." -msgstr "序列模型如何从当前上下文中获得任务行为。" diff --git a/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/Neural_Mechanics.po b/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/Neural_Mechanics.po index 6fb4221..55b681b 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/Neural_Mechanics.po +++ b/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/Neural_Mechanics.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-30 17:56+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: 2026-07-30 00:00+0800\n" "Last-Translator: Comfy Research Docs Team\n" "Language: zh_CN\n" @@ -19,140 +19,137 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" -#: ../../en/examples/reproductions/Neural_Mechanics.md:7 +#: ../../en/examples/reproductions/Neural_Mechanics.md:9 msgid "Loss Geometry and Parameter Symmetry · Phenomenon reproduction" msgstr "损失几何与参数对称性:现象复现" -#: ../../en/examples/reproductions/Neural_Mechanics.md:10 +#: ../../en/examples/reproductions/Neural_Mechanics.md:12 msgid "" "NEURAL MECHANICS: SYMMETRY AND BROKEN CONSERVATION LAWS IN DEEP LEARNING " "DYNAMICS" -msgstr "" -"神经力学:深度学习动力学中的对称性与守恒律破缺" +msgstr "神经力学:深度学习动力学中的对称性与守恒律破缺" -#: ../../en/examples/reproductions/Neural_Mechanics.md:13 +#: ../../en/examples/reproductions/Neural_Mechanics.md:15 msgid "" "Finite learning rates and weight decay break the conservation laws of " "gradient flow. A VGG-11 on CIFAR-10 traces the full spectrum of Eq. 19: " -"monotonic growth, non-monotonic crossover, and monotonic decay of the weight" -" norm." +"monotonic growth, non-monotonic crossover, and monotonic decay of the " +"weight norm." msgstr "" -"有限学习率和权重衰减会破坏梯度流的守恒律。在 CIFAR-10 上训练的 VGG-11 展现了" -"公式 19 所描述的完整权重范数变化谱:单调增长、非单调转变和单调衰减。" +"有限学习率和权重衰减会破坏梯度流的守恒律。在 CIFAR-10 上训练的 VGG-11 展现了公式 19 " +"所描述的完整权重范数变化谱:单调增长、非单调转变和单调衰减。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:23 +#: ../../en/examples/reproductions/Neural_Mechanics.md:25 msgid "ZH" msgstr "浩然" -#: ../../en/examples/reproductions/Neural_Mechanics.md:28 +#: ../../en/examples/reproductions/Neural_Mechanics.md:30 msgid "Author" msgstr "作者" -#: ../../en/examples/reproductions/Neural_Mechanics.md:36 +#: ../../en/examples/reproductions/Neural_Mechanics.md:38 msgid "Scope" msgstr "范围" -#: ../../en/examples/reproductions/Neural_Mechanics.md:38 +#: ../../en/examples/reproductions/Neural_Mechanics.md:40 msgid "**Phenomenon reproduction**" msgstr "**现象复现**" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Abstract" msgstr "摘要" -#: ../../en/examples/reproductions/Neural_Mechanics.md:43 -msgid "" -"Neural network architectures embed continuous differentiable symmetries -- " -"translation (softmax), scale (batch normalization), and rescale (ReLU) -- " -"that impose geometric constraints on gradients and Hessians. Under gradient " -"flow ($\\eta \\to 0$, $\\lambda = 0$), each symmetry gives rise to a strict " -"conservation law. Finite learning rates and weight decay break these " -"conservation laws, yielding exact integral expressions for the dynamics of " -"the previously conserved quantities. This reproduction trains a VGG-11 on " -"CIFAR-10 with SGD to verify the qualitative predictions of the scale " -"symmetry dynamics (Eq. 19) across three weight decay regimes." +#: ../../en/examples/reproductions/Neural_Mechanics.md:45 +msgid "" +"Neural network architectures embed continuous differentiable symmetries " +"-- translation (softmax), scale (batch normalization), and rescale " +"(ReLU) -- that impose geometric constraints on gradients and Hessians. " +"Under gradient flow ($\\eta \\to 0$, $\\lambda = 0$), each symmetry gives" +" rise to a strict conservation law. Finite learning rates and weight " +"decay break these conservation laws, yielding exact integral expressions " +"for the dynamics of the previously conserved quantities. This " +"reproduction trains a VGG-11 on CIFAR-10 with SGD to verify the " +"qualitative predictions of the scale symmetry dynamics (Eq. 19) across " +"three weight decay regimes." msgstr "" -"神经网络架构蕴含连续可微的对称性——平移(Softmax)、尺度(BatchNorm)和重缩放" -"(ReLU)——这些对称性对梯度和 Hessian 施加几何约束。在梯度流($\\eta \\to 0$、" -"$\\lambda = 0$)下,每种对称性都会产生严格的守恒定律。有限学习率和权重衰减" -"会破坏这些守恒律,并为原本守恒量的动力学导出精确的积分表达式。本复现实验使用 " -"SGD 在 CIFAR-10 上训练 VGG-11,检验三种权重衰减设置下尺度对称性动力学" -"(公式 19)的定性预测。" - -#: ../../en/examples/reproductions/Neural_Mechanics.md:54 -msgid "" -"**Paper:** [Neural Mechanics: Symmetry and Broken Conservation Laws in Deep " -"Learning Dynamics](https://arxiv.org/abs/2012.04728), Kunin, Sagastuy-Brena," -" Ganguli, Yamins & Tanaka, ICLR 2021 (arXiv:2012.04728)." +"神经网络架构蕴含连续可微的对称性——平移(Softmax)、尺度(BatchNorm)和重缩放(ReLU)——这些对称性对梯度和 Hessian " +"施加几何约束。在梯度流($\\eta \\to 0$、$\\lambda = " +"0$)下,每种对称性都会产生严格的守恒定律。有限学习率和权重衰减会破坏这些守恒律,并为原本守恒量的动力学导出精确的积分表达式。本复现实验使用 " +"SGD 在 CIFAR-10 上训练 VGG-11,检验三种权重衰减设置下尺度对称性动力学(公式 19)的定性预测。" + +#: ../../en/examples/reproductions/Neural_Mechanics.md:56 +msgid "" +"**Paper:** [Neural Mechanics: Symmetry and Broken Conservation Laws in " +"Deep Learning Dynamics](https://arxiv.org/abs/2012.04728), Kunin, " +"Sagastuy-Brena, Ganguli, Yamins & Tanaka, ICLR 2021 (arXiv:2012.04728)." msgstr "" "**论文:** [Neural Mechanics: Symmetry and Broken Conservation Laws in Deep " -"Learning Dynamics](https://arxiv.org/abs/2012.04728),Kunin、Sagastuy-Brena、" -"Ganguli、Yamins 与 Tanaka,ICLR 2021(arXiv:2012.04728)。" +"Learning Dynamics](https://arxiv.org/abs/2012.04728),Kunin、Sagastuy-" +"Brena、Ganguli、Yamins 与 Tanaka,ICLR 2021(arXiv:2012.04728)。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:58 +#: ../../en/examples/reproductions/Neural_Mechanics.md:60 msgid "**Template:** `Symmetry & Broken Conservation Laws (GPU)`" msgstr "**模板:** `Symmetry & Broken Conservation Laws (GPU)`" -#: ../../en/examples/reproductions/Neural_Mechanics.md:60 -msgid "**Template ID:** `symmetry-broken-conservation-laws`" -msgstr "**模板 ID:** `symmetry-broken-conservation-laws`" - #: ../../en/examples/reproductions/Neural_Mechanics.md:62 +msgid "**Template ID:** `b38ae9dd-735b-46c4-973f-a850a2a55544`" +msgstr "**模板 ID:** `b38ae9dd-735b-46c4-973f-a850a2a55544`" + +#: ../../en/examples/reproductions/Neural_Mechanics.md:64 msgid "Reproduction Goal" msgstr "复现目标" -#: ../../en/examples/reproductions/Neural_Mechanics.md:64 +#: ../../en/examples/reproductions/Neural_Mechanics.md:66 msgid "" -"Kunin et al. (2021) showed that any continuous differentiable symmetry of " -"the training loss imposes geometric constraints on gradients and Hessians, " -"leading to an associated conservation law in the continuous-time limit of " -"SGD (gradient flow), akin to Noether's theorem in physics. Finite learning " -"rates and weight decay break these conservation laws, and the resulting " -"dynamics can be described by exact integral expressions." +"Kunin et al. (2021) showed that any continuous differentiable symmetry of" +" the training loss imposes geometric constraints on gradients and " +"Hessians, leading to an associated conservation law in the continuous-" +"time limit of SGD (gradient flow), akin to Noether's theorem in physics. " +"Finite learning rates and weight decay break these conservation laws, and" +" the resulting dynamics can be described by exact integral expressions." msgstr "" -"Kunin 等人(2021)指出,训练损失的任何连续可微对称性都会对梯度和 Hessian " -"施加几何约束,并在 SGD 的连续时间极限(梯度流)中产生相应的守恒律,类似于" -"物理学中的 Noether 定理。有限学习率和权重衰减会破坏这些守恒律,由此产生的动力学" -"可由精确积分表达式刻画。" +"Kunin 等人(2021)指出,训练损失的任何连续可微对称性都会对梯度和 Hessian 施加几何约束,并在 SGD " +"的连续时间极限(梯度流)中产生相应的守恒律,类似于物理学中的 Noether " +"定理。有限学习率和权重衰减会破坏这些守恒律,由此产生的动力学可由精确积分表达式刻画。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:71 +#: ../../en/examples/reproductions/Neural_Mechanics.md:73 msgid "Three symmetries are identified in standard architectures:" msgstr "标准架构中可识别出三种对称性:" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Symmetry" msgstr "对称性" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Origin" msgstr "来源" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Conserved quantity (gradient flow)" msgstr "守恒量(梯度流下)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Broken dynamics" msgstr "破缺动力学" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Paper Eq." msgstr "论文公式" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Translation" msgstr "平移" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Softmax (classifier weights and bias)" msgstr "Softmax(分类器权重与偏置)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 #, python-brace-format msgid "$\\langle \\theta(t), \\mathbf{1}_A \\rangle$" msgstr "$\\langle \\theta(t), \\mathbf{1}_A \\rangle$" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 #, python-brace-format msgid "" "$\\langle \\theta(t), \\mathbf{1}_A \\rangle = e^{-\\lambda t} \\langle " @@ -161,23 +158,23 @@ msgstr "" "$\\langle \\theta(t), \\mathbf{1}_A \\rangle = e^{-\\lambda t} \\langle " "\\theta(0), \\mathbf{1}_A \\rangle$" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "(18)" msgstr "(18)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Scale" msgstr "尺度" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "BatchNorm ($\\gamma, \\beta$)" msgstr "BatchNorm($\\gamma, \\beta$)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "$|\\theta_A(t)|^2$" msgstr "$|\\theta_A(t)|^2$" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 #, python-brace-format msgid "" "$|\\theta_A(t)|^2 = e^{-2\\lambda t} |\\theta_A(0)|^2 + \\eta \\int_0^t " @@ -186,287 +183,278 @@ msgstr "" "$|\\theta_A(t)|^2 = e^{-2\\lambda t} |\\theta_A(0)|^2 + \\eta \\int_0^t " "e^{-2\\lambda(t-\\tau)} |g_A|^2 \\mathrm{d}\\tau$" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "(19)" msgstr "(19)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Rescale" msgstr "重缩放" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "ReLU-connected consecutive layers" msgstr "由 ReLU 连接的相邻层" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 #, python-brace-format msgid "$|\\theta_{A1}(t)|^2 - |\\theta_{A2}(t)|^2$" msgstr "$|\\theta_{A1}(t)|^2 - |\\theta_{A2}(t)|^2$" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "same form with difference of gradient norms in the integral" msgstr "形式相同,但积分项为梯度范数之差" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "(20)" msgstr "(20)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:79 +#: ../../en/examples/reproductions/Neural_Mechanics.md:81 msgid "The time derivative of Eq. 19," msgstr "对公式 19 求时间导数,可得" -#: ../../en/examples/reproductions/Neural_Mechanics.md:81 +#: ../../en/examples/reproductions/Neural_Mechanics.md:83 #, python-brace-format msgid "" "\n" -"\\frac{d}{dt}\\|\\theta_A(t)\\|^2 = -2\\lambda \\|\\theta_A(t)\\|^2 + \\eta " -"\\|g_A\\|^2,\n" +"\\frac{d}{dt}\\|\\theta_A(t)\\|^2 = -2\\lambda \\|\\theta_A(t)\\|^2 + " +"\\eta \\|g_A\\|^2,\n" msgstr "" "\n" -"\\frac{d}{dt}\\|\\theta_A(t)\\|^2 = -2\\lambda \\|\\theta_A(t)\\|^2 + \\eta " -"\\|g_A\\|^2,\n" - -#: ../../en/examples/reproductions/Neural_Mechanics.md:85 -msgid "" -"reveals a competition between two forces: a **centripetal effect** due to " -"weight decay ($-2\\lambda \\|\\theta_A\\|^2$) and a **centrifugal effect** " -"due to discretization ($\\eta \\|g_A\\|^2$). At $\\lambda = 0$, only the " -"centrifugal term operates and the norm grows monotonically. At sufficiently " -"large $\\lambda$, the centripetal term dominates and the norm decays. At " -"intermediate $\\lambda$, the two terms trade dominance as the gradient norm " -"evolves through training, producing non-monotonic dynamics." +"\\frac{d}{dt}\\|\\theta_A(t)\\|^2 = -2\\lambda \\|\\theta_A(t)\\|^2 + " +"\\eta \\|g_A\\|^2,\n" + +#: ../../en/examples/reproductions/Neural_Mechanics.md:87 +msgid "" +"reveals a competition between two forces: a **centripetal effect** due to" +" weight decay ($-2\\lambda \\|\\theta_A\\|^2$) and a **centrifugal " +"effect** due to discretization ($\\eta \\|g_A\\|^2$). At $\\lambda = 0$, " +"only the centrifugal term operates and the norm grows monotonically. At " +"sufficiently large $\\lambda$, the centripetal term dominates and the " +"norm decays. At intermediate $\\lambda$, the two terms trade dominance as" +" the gradient norm evolves through training, producing non-monotonic " +"dynamics." msgstr "" -"这揭示了两种效应之间的竞争:权重衰减带来的**向心效应**" -"($-2\\lambda \\|\\theta_A\\|^2$)与离散化产生的**离心效应**" -"($\\eta \\|g_A\\|^2$)。当 $\\lambda = 0$ 时,只有离心项起作用,范数单调" -"增长。当 $\\lambda$ 足够大时,向心项占主导,范数随之下降。当 $\\lambda$ " -"处于中等范围时,随着训练过程中梯度范数的演化,两项的主导地位发生转换,从而产生" -"非单调动力学。" +"这揭示了两种效应之间的竞争:权重衰减带来的**向心效应**($-2\\lambda " +"\\|\\theta_A\\|^2$)与离散化产生的**离心效应**($\\eta \\|g_A\\|^2$)。当 $\\lambda = 0$ " +"时,只有离心项起作用,范数单调增长。当 $\\lambda$ 足够大时,向心项占主导,范数随之下降。当 $\\lambda$ " +"处于中等范围时,随着训练过程中梯度范数的演化,两项的主导地位发生转换,从而产生非单调动力学。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:93 +#: ../../en/examples/reproductions/Neural_Mechanics.md:95 msgid "" "This reproduction focuses on **scale symmetry** (Eq. 19) in the batch " -"normalization layers of a VGG-11 model, tracking the global weight L2 norm " -"$\\|\\theta(t)\\|$ as a proxy for the aggregate of all BN scale parameter " -"norms. Three weight decay settings span the full spectrum: zero, " -"intermediate, and strong." +"normalization layers of a VGG-11 model, tracking the global weight L2 " +"norm $\\|\\theta(t)\\|$ as a proxy for the aggregate of all BN scale " +"parameter norms. Three weight decay settings span the full spectrum: " +"zero, intermediate, and strong." msgstr "" -"本复现实验聚焦于 VGG-11 的 BatchNorm 层中的**尺度对称性**(公式 19),以全局" -"权重 L2 范数 $\\|\\theta(t)\\|$ 近似表征所有 BN 尺度参数范数的总和。三种" -"权重衰减设置覆盖完整范围:零、中等和较强。" +"本复现实验聚焦于 VGG-11 的 BatchNorm 层中的**尺度对称性**(公式 19),以全局权重 L2 范数 " +"$\\|\\theta(t)\\|$ 近似表征所有 BN 尺度参数范数的总和。三种权重衰减设置覆盖完整范围:零、中等和较强。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:99 +#: ../../en/examples/reproductions/Neural_Mechanics.md:101 msgid "Paper Experiment and Reproduction Boundary" msgstr "论文实验与复现边界" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Item" msgstr "项目" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Paper" msgstr "论文" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Current Comfy Research template" msgstr "当前 Comfy Research 模板" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Claim" msgstr "主张" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "" "Finite learning rate and weight decay break the conservation laws of " "gradient flow; scale symmetry dynamics follow Eq. 19 with a competition " "between centripetal and centrifugal effects" -msgstr "" -"有限学习率和权重衰减会破坏梯度流的守恒律;尺度对称性动力学遵循公式 19," -"体现为向心效应与离心效应之间的竞争" +msgstr "有限学习率和权重衰减会破坏梯度流的守恒律;尺度对称性动力学遵循公式 19,体现为向心效应与离心效应之间的竞争" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "" -"Same qualitative signature: monotonic growth ($\\lambda = 0$), non-monotonic" -" ($\\lambda$ small), monotonic decay ($\\lambda$ large)" -msgstr "" -"相同的定性特征:单调增长($\\lambda = 0$)、非单调变化($\\lambda$ 较小)、" -"单调衰减($\\lambda$ 较大)" +"Same qualitative signature: monotonic growth ($\\lambda = 0$), non-" +"monotonic ($\\lambda$ small), monotonic decay ($\\lambda$ large)" +msgstr "相同的定性特征:单调增长($\\lambda = 0$)、非单调变化($\\lambda$ 较小)、单调衰减($\\lambda$ 较大)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Model" msgstr "模型" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "VGG-16 with batch normalization, ~138M parameters" msgstr "带 BatchNorm 的 VGG-16,约 1.38 亿个参数" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "VGG-11 with batch normalization, ~9.7M parameters" msgstr "带 BatchNorm 的 VGG-11,约 970 万个参数" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Dataset" msgstr "数据集" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Tiny ImageNet (200 classes, 64×64, 100k samples)" msgstr "Tiny ImageNet(200 类、64×64、10 万个样本)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "CIFAR-10 (10 classes, 32×32," msgstr "CIFAR-10(10 类、32×32," -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Optimizer" msgstr "优化器" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "SGD, $\\eta = 0.1$, momentum 0, batch size 256" msgstr "SGD,$\\eta = 0.1$,动量为 0,批量大小为 256" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Same settings" msgstr "设置相同" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Weight decay" msgstr "权重衰减" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "$\\lambda \\in \\{0, 10^{-4}, 10^{-3}\\}$" msgstr "$\\lambda \\in \\{0, 10^{-4}, 10^{-3}\\}$" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "$\\lambda \\in \\{0, 5\\times10^{-5}, 0.01\\}$" msgstr "$\\lambda \\in \\{0, 5\\times10^{-5}, 0.01\\}$" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Training" msgstr "训练" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "100 epochs" msgstr "100 个 epoch" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "8000 steps (fixed step budget)" msgstr "8000 步(固定步数预算)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Observable" msgstr "observable" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Per-layer squared channel norms $|W_l|^2$ for specific conv layers" msgstr "指定卷积层中各层通道范数的平方 $|W_l|^2$" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Global weight L2 norm (all BN layers aggregated) and gradient norm" msgstr "全局权重 L2 范数(汇总所有 BN 层)和梯度范数" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Device" msgstr "设备" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "(not specified)" msgstr "(未说明)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Remote GPU" msgstr "远程 GPU" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Runtime goal" msgstr "运行目的" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Empirical validation of Eq. 18-20 on large-scale models" msgstr "在大规模模型上对公式 18–20 进行实证验证" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Qualitative demonstration of the centripetal-centrifugal competition" msgstr "定性展示向心效应与离心效应之间的竞争" -#: ../../en/examples/reproductions/Neural_Mechanics.md:113 +#: ../../en/examples/reproductions/Neural_Mechanics.md:115 msgid "" -"This graph tests a **mechanism-level pattern** -- the qualitative dependence " -"of the weight norm trajectory on $\\lambda$ -- rather than a point-by-point " -"match to the paper's Fig. 5. The use of global weight L2 norm (rather than " -"per-BN-layer norms) and a smaller model/dataset means the curves are not " -"directly comparable to the paper's, but the qualitative signatures of Eq. 19" -" are preserved." +"This graph tests a **mechanism-level pattern** -- the qualitative " +"dependence of the weight norm trajectory on $\\lambda$ -- rather than a" +" point-by-point match to the paper's Fig. 5. The use of global weight L2 " +"norm (rather than per-BN-layer norms) and a smaller model/dataset means " +"the curves are not directly comparable to the paper's, but the " +"qualitative signatures of Eq. 19 are preserved." msgstr "" -"该计算图检验的是一种**机制层面的规律**——权重范数轨迹对 $\\lambda$ 的定性" -"依赖,而非对论文图 5 的逐点复现。由于采用全局权重 L2 范数(而非各个 BN 层的" -"范数)以及更小的模型和数据集,所得曲线不能与论文曲线直接比较,但仍保留了公式 19" -"的定性特征。" +"该计算图检验的是一种**机制层面的规律**——权重范数轨迹对 $\\lambda$ 的定性依赖,而非对论文图 5 的逐点复现。由于采用全局权重 " +"L2 范数(而非各个 BN 层的范数)以及更小的模型和数据集,所得曲线不能与论文曲线直接比较,但仍保留了公式 19的定性特征。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:120 +#: ../../en/examples/reproductions/Neural_Mechanics.md:122 msgid "Experiment Configuration" msgstr "实验配置" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Template setting" msgstr "模板设置" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "CIFAR-10 (32×32 RGB, 10 classes)," msgstr "CIFAR-10(32×32 RGB、10 类)," -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "" "VGG-11 CIFAR: 8 Conv2D layers each followed by BatchNorm2d + ReLU, with " "MaxPool after blocks 2/4/6/8; 3 FC layers (512→512→10); ~9.7M parameters" msgstr "" -"VGG-11 CIFAR:8 个 Conv2D 层,每层后接 BatchNorm2d + ReLU;在第 2/4/6/8 个" -"模块后使用 MaxPool;3 个全连接层(512→512→10);约 970 万个参数" +"VGG-11 CIFAR:8 个 Conv2D 层,每层后接 BatchNorm2d + ReLU;在第 2/4/6/8 个模块后使用 " +"MaxPool;3 个全连接层(512→512→10);约 970 万个参数" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Loss" msgstr "损失函数" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Cross-entropy (with implicit softmax → translation symmetry in classifier)" -msgstr "" -"交叉熵(隐含 Softmax → 分类器中的平移对称性)" +msgstr "交叉熵(隐含 Softmax → 分类器中的平移对称性)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 -msgid "SGD, learning rate $\\eta = 0.1$, momentum 0, weight decay $\\lambda$ varied" -msgstr "" -"SGD,学习率 $\\eta = 0.1$,动量为 0,改变权重衰减 $\\lambda$ 的取值" +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 +msgid "" +"SGD, learning rate $\\eta = 0.1$, momentum 0, weight decay $\\lambda$ " +"varied" +msgstr "SGD,学习率 $\\eta = 0.1$,动量为 0,改变权重衰减 $\\lambda$ 的取值" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Batch size" msgstr "批量大小" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "256" msgstr "256" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "8000 steps, log every" msgstr "8000 步,按设定间隔记录" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Observables" msgstr "observable" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "" "Global weight L2 norm $|\\theta(t)|$ (`observable_weight_l2`, " -"`normAggregation=global`); Global gradient norm (`observable_gradient_norm`," -" normalized)" +"`normAggregation=global`); Global gradient norm " +"(`observable_gradient_norm`, normalized)" msgstr "" -"全局权重 L2 范数 $|\\theta(t)|$(`observable_weight_l2`," -"`normAggregation=global`);全局梯度范数(`observable_gradient_norm`," -"已归一化)" +"全局权重 L2 范数 " +"$|\\theta(t)|$(`observable_weight_l2`,`normAggregation=global`);全局梯度范数(`observable_gradient_norm`,已归一化)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 msgid "Experiment groups" msgstr "实验组" -#: ../../en/examples/reproductions/Neural_Mechanics.md:40 +#: ../../en/examples/reproductions/Neural_Mechanics.md:42 #, python-brace-format msgid "" "**A**: $\\eta = 0.1$, $\\lambda = 0$ | **B**: $\\eta = 0.1$, $\\lambda = " @@ -475,38 +463,37 @@ msgstr "" "**A**:$\\eta = 0.1$,$\\lambda = 0$ | **B**:$\\eta = 0.1$,$\\lambda = " "5\\times10^{-5}$ | **C**:$\\eta = 0.1$,$\\lambda = 0.01$" -#: ../../en/examples/reproductions/Neural_Mechanics.md:134 +#: ../../en/examples/reproductions/Neural_Mechanics.md:136 #, python-brace-format msgid "" "The global weight L2 norm $\\|\\theta(t)\\| = \\sqrt{\\sum_p p^2}$ " -"aggregates over all trainable parameters (convolution kernels, BN $\\gamma$ " -"and $\\beta$, FC weights and biases). Since all BN layers share the same " -"qualitative Eq. 19 dynamics, the global norm inherits the same qualitative " -"dependence on $\\eta$ and $\\lambda$. The gradient norm serves as a " -"diagnostic for the centrifugal driving term: when $\\|g\\|^2$ is large, the " -"integral term in Eq. 19 accumulates rapidly; when it decays, the centripetal" -" term may overtake the centrifugal term if $\\lambda > 0$." -msgstr "" -"全局权重 L2 范数 $\\|\\theta(t)\\| = \\sqrt{\\sum_p p^2}$ 聚合了所有可训练" -"参数(卷积核、BN 的 $\\gamma$ 和 $\\beta$、全连接层权重与偏置)。由于所有 " -"BN 层都遵循公式 19 所描述的相同定性动力学,全局范数对 $\\eta$ 和 $\\lambda$ " -"也具有相同的定性依赖。梯度范数可用于诊断离心驱动项:当 $\\|g\\|^2$ 较大时," -"公式 19 中的积分项快速累积;当它衰减时,若 $\\lambda > 0$,向心项可能超过" -"离心项。" - -#: ../../en/examples/reproductions/Neural_Mechanics.md:143 +"aggregates over all trainable parameters (convolution kernels, BN " +"$\\gamma$ and $\\beta$, FC weights and biases). Since all BN layers share" +" the same qualitative Eq. 19 dynamics, the global norm inherits the same " +"qualitative dependence on $\\eta$ and $\\lambda$. The gradient norm " +"serves as a diagnostic for the centrifugal driving term: when $\\|g\\|^2$" +" is large, the integral term in Eq. 19 accumulates rapidly; when it " +"decays, the centripetal term may overtake the centrifugal term if " +"$\\lambda > 0$." +msgstr "" +"全局权重 L2 范数 $\\|\\theta(t)\\| = \\sqrt{\\sum_p p^2}$ 聚合了所有可训练参数(卷积核、BN 的 " +"$\\gamma$ 和 $\\beta$、全连接层权重与偏置)。由于所有 BN 层都遵循公式 19 所描述的相同定性动力学,全局范数对 " +"$\\eta$ 和 $\\lambda$ 也具有相同的定性依赖。梯度范数可用于诊断离心驱动项:当 $\\|g\\|^2$ 较大时,公式 19 " +"中的积分项快速累积;当它衰减时,若 $\\lambda > 0$,向心项可能超过离心项。" + +#: ../../en/examples/reproductions/Neural_Mechanics.md:145 msgid "Run in Comfy Research" msgstr "在 Comfy Research 中运行" -#: ../../en/examples/reproductions/Neural_Mechanics.md:145 +#: ../../en/examples/reproductions/Neural_Mechanics.md:147 msgid "" -"Open **Templates** and load **`Symmetry & Broken Conservation Laws (GPU)`** " -"(or import the workspace JSON)." +"Open **Templates** and load **`Symmetry & Broken Conservation Laws " +"(GPU)`** (or import the workspace JSON)." msgstr "" "打开 **Templates**,加载 **`Symmetry & Broken Conservation Laws " "(GPU)`**(或导入工作区 JSON)。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:147 +#: ../../en/examples/reproductions/Neural_Mechanics.md:149 msgid "" "Confirm the graph: `cifar10_dataset` → `trainer`, `vgg11_cifar_model` → " "`trainer`, `cross_entropy_loss` → `trainer`, `sgd_optimizer` → `trainer`." @@ -514,323 +501,311 @@ msgstr "" "确认计算图连接:`cifar10_dataset` → `trainer`、`vgg11_cifar_model` → " "`trainer`、`cross_entropy_loss` → `trainer`、`sgd_optimizer` → `trainer`。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:149 +#: ../../en/examples/reproductions/Neural_Mechanics.md:151 msgid "Verify Trainer settings: GPU, 8000 steps, batch size 256, momentum 0." msgstr "确认 Trainer 设置:GPU、8000 步、批量大小 256、动量为 0。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:150 +#: ../../en/examples/reproductions/Neural_Mechanics.md:152 msgid "" "Verify observables: `observable_weight_l2` (global) and " "`observable_gradient_norm` (normalized) are registered in the Trainer's " "observables panel." msgstr "" -"确认 Trainer 的 observables 面板中已注册 `observable_weight_l2`(全局)" -"和 `observable_gradient_norm`(已归一化)。" +"确认 Trainer 的 observables 面板中已注册 `observable_weight_l2`(全局)和 " +"`observable_gradient_norm`(已归一化)。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:153 +#: ../../en/examples/reproductions/Neural_Mechanics.md:155 msgid "**Experiment A**: set SGD `weightDecay = 0`. Click **Train**." msgstr "**实验 A**:将 SGD 的 `weightDecay` 设为 `0`。点击 **Train**。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:154 +#: ../../en/examples/reproductions/Neural_Mechanics.md:156 msgid "**Experiment B**: set SGD `weightDecay = 5e-5`. Click **Train**." msgstr "**实验 B**:将 SGD 的 `weightDecay` 设为 `5e-5`。点击 **Train**。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:155 +#: ../../en/examples/reproductions/Neural_Mechanics.md:157 msgid "**Experiment C**: set SGD `weightDecay = 0.01`. Click **Train**." msgstr "**实验 C**:将 SGD 的 `weightDecay` 设为 `0.01`。点击 **Train**。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:156 +#: ../../en/examples/reproductions/Neural_Mechanics.md:158 msgid "" "Compare the three runs in the Observables panel to see the full " "centripetal-centrifugal spectrum." -msgstr "" -"在 Observables 面板中比较三次运行,即可看到完整的向心—离心变化谱。" +msgstr "在 Observables 面板中比较三次运行,即可看到完整的向心—离心变化谱。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:159 +#: ../../en/examples/reproductions/Neural_Mechanics.md:161 msgid "Results" msgstr "结果" -#: ../../en/examples/reproductions/Neural_Mechanics.md:161 +#: ../../en/examples/reproductions/Neural_Mechanics.md:163 msgid "" -"The global weight L2 norm $\\|\\theta(t)\\|$ exhibits the three qualitative " -"regimes predicted by Eq. 19:" -msgstr "" -"全局权重 L2 范数 $\\|\\theta(t)\\|$ 呈现公式 19 预测的三种定性区间:" +"The global weight L2 norm $\\|\\theta(t)\\|$ exhibits the three " +"qualitative regimes predicted by Eq. 19:" +msgstr "全局权重 L2 范数 $\\|\\theta(t)\\|$ 呈现公式 19 预测的三种定性区间:" -#: ../../en/examples/reproductions/Neural_Mechanics.md:164 +#: ../../en/examples/reproductions/Neural_Mechanics.md:166 msgid "Experiment A: $\\lambda = 0$ (centrifugal effect only)" msgstr "实验 A:$\\lambda = 0$(仅有离心效应)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:166 +#: ../../en/examples/reproductions/Neural_Mechanics.md:168 #, python-brace-format msgid "" "The weight L2 norm **rises monotonically** throughout training. With no " "weight decay, the first term of Eq. 19 stays constant at " "$\\|\\theta(0)\\|^2$ and the integral term $\\eta \\int \\|g\\|^2 " -"\\mathrm{d}\\tau$ accumulates monotonically, driving the norm upward. The " -"gradient norm peaks early (high initial loss) then decays as training " -"converges, but even the reduced late-training gradient contributes positive " -"accumulation to the integral -- there is no countervailing force." +"\\mathrm{d}\\tau$ accumulates monotonically, driving the norm upward. The" +" gradient norm peaks early (high initial loss) then decays as training " +"converges, but even the reduced late-training gradient contributes " +"positive accumulation to the integral -- there is no countervailing " +"force." msgstr "" -"权重 L2 范数在整个训练过程中**单调上升**。在没有权重衰减时,公式 19 的第一项" -"保持为常量 $\\|\\theta(0)\\|^2$,积分项 $\\eta \\int \\|g\\|^2 " -"\\mathrm{d}\\tau$ 则单调累积,推动范数上升。梯度范数在早期达到峰值(初始" -"损失较高),随后随着训练收敛而减小;但即使在训练后期,较小的梯度仍会为积分项" -"带来正向累积——没有任何相反作用与之抗衡。" +"权重 L2 范数在整个训练过程中**单调上升**。在没有权重衰减时,公式 19 的第一项保持为常量 " +"$\\|\\theta(0)\\|^2$,积分项 $\\eta \\int \\|g\\|^2 \\mathrm{d}\\tau$ " +"则单调累积,推动范数上升。梯度范数在早期达到峰值(初始损失较高),随后随着训练收敛而减小;但即使在训练后期,较小的梯度仍会为积分项带来正向累积——没有任何相反作用与之抗衡。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:174 +#: ../../en/examples/reproductions/Neural_Mechanics.md:176 msgid "" -"Comfy Research graph for Experiment A: VGG-11 on CIFAR-10, η = 0.1, λ = 0. " -"Weight L2 norm rises monotonically." +"Comfy Research graph for Experiment A: VGG-11 on CIFAR-10, η = 0.1, λ = " +"0. Weight L2 norm rises monotonically." msgstr "" -"实验 A 的 Comfy Research 计算图:在 CIFAR-10 上训练 VGG-11,η = 0.1," -"λ = 0。权重 L2 范数单调上升。" +"实验 A 的 Comfy Research 计算图:在 CIFAR-10 上训练 VGG-11,η = 0.1,λ = 0。权重 L2 " +"范数单调上升。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:179 +#: ../../en/examples/reproductions/Neural_Mechanics.md:181 #, python-brace-format msgid "Experiment B: $\\lambda = 5 \\times 10^{-5}$ (competitive regime)" msgstr "实验 B:$\\lambda = 5 \\times 10^{-5}$(竞争区间)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:181 +#: ../../en/examples/reproductions/Neural_Mechanics.md:183 msgid "" -"The weight L2 norm **rises then falls** -- a non-monotonic trajectory. Early " -"in training, the gradient norm is large and the centrifugal term $\\eta " -"\\|g\\|^2$ exceeds the centripetal term $2\\lambda \\|\\theta\\|^2$, so the " -"norm grows. As training converges and $\\|g\\|^2$ decays, the centripetal " -"term gradually overtakes the centrifugal term, and the norm begins to " -"decrease. The peak marks the crossover where $2\\lambda \\|\\theta\\|^2 = " -"\\eta \\|g\\|^2$. This is the most direct qualitative signature of the " -"competition described by Eq. 19." +"The weight L2 norm **rises then falls** -- a non-monotonic trajectory. " +"Early in training, the gradient norm is large and the centrifugal term " +"$\\eta \\|g\\|^2$ exceeds the centripetal term $2\\lambda " +"\\|\\theta\\|^2$, so the norm grows. As training converges and " +"$\\|g\\|^2$ decays, the centripetal term gradually overtakes the " +"centrifugal term, and the norm begins to decrease. The peak marks the " +"crossover where $2\\lambda \\|\\theta\\|^2 = \\eta \\|g\\|^2$. This is " +"the most direct qualitative signature of the competition described by Eq." +" 19." msgstr "" -"权重 L2 范数**先升后降**,形成非单调轨迹。训练早期,梯度范数较大,离心项 " -"$\\eta \\|g\\|^2$ 超过向心项 $2\\lambda \\|\\theta\\|^2$,因此范数增长。" -"随着训练收敛、$\\|g\\|^2$ 衰减,向心项逐渐超过离心项,范数开始下降。峰值" -"对应 $2\\lambda \\|\\theta\\|^2 = \\eta \\|g\\|^2$ 的交叉点。这是公式 " -"19 所描述竞争关系最直接的定性特征。" +"权重 L2 范数**先升后降**,形成非单调轨迹。训练早期,梯度范数较大,离心项 $\\eta \\|g\\|^2$ 超过向心项 " +"$2\\lambda \\|\\theta\\|^2$,因此范数增长。随着训练收敛、$\\|g\\|^2$ " +"衰减,向心项逐渐超过离心项,范数开始下降。峰值对应 $2\\lambda \\|\\theta\\|^2 = \\eta \\|g\\|^2$ " +"的交叉点。这是公式 19 所描述竞争关系最直接的定性特征。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:189 +#: ../../en/examples/reproductions/Neural_Mechanics.md:191 msgid "" "Comfy Research graph for Experiment B: VGG-11 on CIFAR-10, η = 0.1, λ = " "5×10⁻⁵. Weight L2 norm rises then falls -- non-monotonic dynamics." msgstr "" -"实验 B 的 Comfy Research 计算图:在 CIFAR-10 上训练 VGG-11,η = 0.1," -"λ = 5×10⁻⁵。权重 L2 范数先升后降,呈现非单调动力学。" +"实验 B 的 Comfy Research 计算图:在 CIFAR-10 上训练 VGG-11,η = 0.1,λ = 5×10⁻⁵。权重 L2 " +"范数先升后降,呈现非单调动力学。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:194 +#: ../../en/examples/reproductions/Neural_Mechanics.md:196 msgid "Experiment C: $\\lambda = 0.01$ (centripetal effect dominant)" msgstr "实验 C:$\\lambda = 0.01$(向心效应占主导)" -#: ../../en/examples/reproductions/Neural_Mechanics.md:196 +#: ../../en/examples/reproductions/Neural_Mechanics.md:198 #, python-brace-format msgid "" "The weight L2 norm **falls monotonically**. The exponential decay factor " "$e^{-2\\lambda t}$ in Eq. 19 suppresses both the memory term and the " -"integral term. With $\\lambda = 0.01$, the centripetal effect dominates from" -" the start -- the decay rate outstrips any accumulation from the centrifugal " -"term." +"integral term. With $\\lambda = 0.01$, the centripetal effect dominates " +"from the start -- the decay rate outstrips any accumulation from the " +"centrifugal term." msgstr "" -"权重 L2 范数**单调下降**。公式 19 中的指数衰减因子 $e^{-2\\lambda t}$ " -"同时抑制记忆项和积分项。当 $\\lambda = 0.01$ 时,向心效应从一开始就占据" -"主导——衰减速率超过离心项带来的任何累积。" +"权重 L2 范数**单调下降**。公式 19 中的指数衰减因子 $e^{-2\\lambda t}$ 同时抑制记忆项和积分项。当 " +"$\\lambda = 0.01$ 时,向心效应从一开始就占据主导——衰减速率超过离心项带来的任何累积。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "" "Comfy Research graph for Experiment C: VGG-11 on CIFAR-10, η = 0.1, λ = " "0.01. Weight L2 norm falls monotonically." msgstr "" -"实验 C 的 Comfy Research 计算图:在 CIFAR-10 上训练 VGG-11,η = 0.1," -"λ = 0.01。权重 L2 范数单调下降。" +"实验 C 的 Comfy Research 计算图:在 CIFAR-10 上训练 VGG-11,η = 0.1,λ = 0.01。权重 L2 " +"范数单调下降。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:206 +#: ../../en/examples/reproductions/Neural_Mechanics.md:208 msgid "Summary" msgstr "小结" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "Experiment" msgstr "实验" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "$\\eta$" msgstr "$\\eta$" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "$\\lambda$" msgstr "$\\lambda$" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "$|\\theta(t)|$ behavior" msgstr "$|\\theta(t)|$ 的变化" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "Dominant effect" msgstr "主导效应" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "A" msgstr "实验 A" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "0.1" msgstr "0.1" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "0" msgstr "0" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "Monotonic rise" msgstr "单调上升" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "Centrifugal only" msgstr "仅有离心效应" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "B" msgstr "实验 B" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 #, python-brace-format msgid "$5\\times 10^{-5}$" msgstr "$5\\times 10^{-5}$" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "Rise then fall" msgstr "先升后降" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "Crossover: centrifugal → centripetal" msgstr "转折:离心 → 向心" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "C" msgstr "实验 C" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "0.01" msgstr "0.01" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "Monotonic fall" msgstr "单调下降" -#: ../../en/examples/reproductions/Neural_Mechanics.md:201 +#: ../../en/examples/reproductions/Neural_Mechanics.md:203 msgid "Centripetal dominant" msgstr "向心效应占主导" -#: ../../en/examples/reproductions/Neural_Mechanics.md:214 +#: ../../en/examples/reproductions/Neural_Mechanics.md:216 msgid "Interpretation" msgstr "结果解读" -#: ../../en/examples/reproductions/Neural_Mechanics.md:216 +#: ../../en/examples/reproductions/Neural_Mechanics.md:218 msgid "" -"The three experiments span the full qualitative spectrum predicted by the " -"scale symmetry dynamics (Eq. 19):" -msgstr "" -"三个实验覆盖了尺度对称性动力学(公式 19)所预测的完整定性行为谱:" +"The three experiments span the full qualitative spectrum predicted by the" +" scale symmetry dynamics (Eq. 19):" +msgstr "三个实验覆盖了尺度对称性动力学(公式 19)所预测的完整定性行为谱:" -#: ../../en/examples/reproductions/Neural_Mechanics.md:219 +#: ../../en/examples/reproductions/Neural_Mechanics.md:221 msgid "" "**Experiment A** ($\\lambda = 0$): finite learning rate alone breaks the " "conservation law. Under gradient flow $\\|\\theta_A\\|^2$ would be " -"conserved, but discrete updates at finite $\\eta$ introduce a centrifugal " -"effect that pushes the norm monotonically upward." +"conserved, but discrete updates at finite $\\eta$ introduce a centrifugal" +" effect that pushes the norm monotonically upward." msgstr "" -"**实验 A**($\\lambda = 0$):仅有限学习率就会打破守恒定律。在梯度流下," -"$\\|\\theta_A\\|^2$ 本应守恒,但有限 $\\eta$ 下的离散更新引入离心效应," -"推动范数单调上升。" +"**实验 A**($\\lambda = 0$):仅有限学习率就会打破守恒定律。在梯度流下,$\\|\\theta_A\\|^2$ " +"本应守恒,但有限 $\\eta$ 下的离散更新引入离心效应,推动范数单调上升。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:223 +#: ../../en/examples/reproductions/Neural_Mechanics.md:225 #, python-brace-format msgid "" -"**Experiment B** ($\\lambda = 5 \\times 10^{-5}$): early in training, large " -"gradients drive the centrifugal term to dominate; later, as gradients " -"shrink, the centripetal term overtakes it. The crossover where the norm " -"peaks is the dynamic balance $2\\lambda \\|\\theta\\|^2 = \\eta \\|g\\|^2$." +"**Experiment B** ($\\lambda = 5 \\times 10^{-5}$): early in training, " +"large gradients drive the centrifugal term to dominate; later, as " +"gradients shrink, the centripetal term overtakes it. The crossover where " +"the norm peaks is the dynamic balance $2\\lambda \\|\\theta\\|^2 = \\eta " +"\\|g\\|^2$." msgstr "" -"**实验 B**($\\lambda = 5 \\times 10^{-5}$):训练早期,大梯度使离心项" -"占据主导;后期随着梯度减小,向心项反超。范数达到峰值的交叉点即动力学平衡点 " -"$2\\lambda \\|\\theta\\|^2 = \\eta \\|g\\|^2$。" +"**实验 B**($\\lambda = 5 \\times " +"10^{-5}$):训练早期,大梯度使离心项占据主导;后期随着梯度减小,向心项反超。范数达到峰值的交叉点即动力学平衡点 $2\\lambda " +"\\|\\theta\\|^2 = \\eta \\|g\\|^2$。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:227 +#: ../../en/examples/reproductions/Neural_Mechanics.md:229 msgid "" -"**Experiment C** ($\\lambda = 0.01$): weight decay dominates the dynamics " -"from the outset, pulling the norm down despite the centrifugal push." -msgstr "" -"**实验 C**($\\lambda = 0.01$):权重衰减从一开始就主导动力学;尽管存在" -"离心作用,范数仍被拉低。" +"**Experiment C** ($\\lambda = 0.01$): weight decay dominates the dynamics" +" from the outset, pulling the norm down despite the centrifugal push." +msgstr "**实验 C**($\\lambda = 0.01$):权重衰减从一开始就主导动力学;尽管存在离心作用,范数仍被拉低。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:230 +#: ../../en/examples/reproductions/Neural_Mechanics.md:232 msgid "Limitations" msgstr "局限性" -#: ../../en/examples/reproductions/Neural_Mechanics.md:232 +#: ../../en/examples/reproductions/Neural_Mechanics.md:234 msgid "" "The **global weight L2 norm** aggregates all parameters -- convolution " -"kernels, BN $\\gamma$/$\\beta$, and FC weights -- not solely the BN scale " -"parameters that define the scale symmetry group. Each BN layer obeys Eq. 19 " -"independently; the global norm is a superposition and cannot be directly " -"compared to the paper's per-layer curves." +"kernels, BN $\\gamma$/$\\beta$, and FC weights -- not solely the BN " +"scale parameters that define the scale symmetry group. Each BN layer " +"obeys Eq. 19 independently; the global norm is a superposition and cannot" +" be directly compared to the paper's per-layer curves." msgstr "" -"**全局权重 L2 范数**汇总了所有参数——卷积核、BN 的 $\\gamma$/$\\beta$ " -"以及全连接层权重——而不只是定义尺度对称群的 BN 尺度参数。每个 BN 层独立" -"遵循公式 19;全局范数是这些量的叠加,无法与论文中的逐层曲线直接比较。" +"**全局权重 L2 范数**汇总了所有参数——卷积核、BN 的 $\\gamma$/$\\beta$ 以及全连接层权重——而不只是定义尺度对称群的" +" BN 尺度参数。每个 BN 层独立遵循公式 19;全局范数是这些量的叠加,无法与论文中的逐层曲线直接比较。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:237 +#: ../../en/examples/reproductions/Neural_Mechanics.md:239 msgid "" -"The **gradient norm** measures the full gradient across all parameters, not " -"$\\|g_A\\|^2$ for a specific symmetry group. It serves as a qualitative " -"proxy for the centrifugal driving term but cannot be used to quantitatively " -"verify the integral in Eq. 19." +"The **gradient norm** measures the full gradient across all parameters, " +"not $\\|g_A\\|^2$ for a specific symmetry group. It serves as a " +"qualitative proxy for the centrifugal driving term but cannot be used to " +"quantitatively verify the integral in Eq. 19." msgstr "" -"**梯度范数**测量所有参数的完整梯度,而不是特定对称群的 $\\|g_A\\|^2$。它可" -"作为离心驱动项的定性替代指标,但不能用于定量验证公式 19 中的积分项。" +"**梯度范数**测量所有参数的完整梯度,而不是特定对称群的 $\\|g_A\\|^2$。它可作为离心驱动项的定性替代指标,但不能用于定量验证公式 " +"19 中的积分项。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:241 +#: ../../en/examples/reproductions/Neural_Mechanics.md:243 #, python-brace-format msgid "" "This experiment only probes **scale symmetry** (Eq. 19). Translation " -"symmetry (Eq. 18) and rescale symmetry (Eq. 20) are not directly measured -- " -"translation because the `statistics` node in Comfy Research lacks a sum " -"operation for computing $\\langle \\theta, \\mathbf{1}_A \\rangle$, and " -"rescale because `statistics2` does not operate as a training-step " +"symmetry (Eq. 18) and rescale symmetry (Eq. 20) are not directly measured" +" -- translation because the `statistics` node in Comfy Research lacks a" +" sum operation for computing $\\langle \\theta, \\mathbf{1}_A \\rangle$, " +"and rescale because `statistics2` does not operate as a training-step " "observable." msgstr "" -"本实验只探测**尺度对称性**(公式 19),并未直接测量平移对称性(公式 18)和" -"重缩放对称性(公式 20)。前者是因为 Comfy Research 的 `statistics` 节点缺少" -"用于计算 $\\langle \\theta, \\mathbf{1}_A \\rangle$ 的求和操作;后者是因为 " -"`statistics2` 不能作为逐训练步 observable 运行。" +"本实验只探测**尺度对称性**(公式 19),并未直接测量平移对称性(公式 18)和重缩放对称性(公式 20)。前者是因为 Comfy " +"Research 的 `statistics` 节点缺少用于计算 $\\langle \\theta, \\mathbf{1}_A " +"\\rangle$ 的求和操作;后者是因为 `statistics2` 不能作为逐训练步 observable 运行。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:246 +#: ../../en/examples/reproductions/Neural_Mechanics.md:248 msgid "" -"The model (VGG-11, ~9.7M parameters) and dataset (CIFAR-10) are smaller than" -" the paper's VGG-16 + Tiny ImageNet setup, though the symmetry and its " -"dynamic consequences are architectural properties that do not depend on " -"scale." +"The model (VGG-11, ~9.7M parameters) and dataset (CIFAR-10) are smaller " +"than the paper's VGG-16 + Tiny ImageNet setup, though the symmetry and " +"its dynamic consequences are architectural properties that do not depend " +"on scale." msgstr "" -"本实验使用的模型(VGG-11,约 970 万个参数)和数据集(CIFAR-10)小于论文中的 " -"VGG-16 + Tiny ImageNet 配置。不过,对称性及其动力学后果属于架构属性,并不依赖" -"规模。" +"本实验使用的模型(VGG-11,约 970 万个参数)和数据集(CIFAR-10)小于论文中的 VGG-16 + Tiny ImageNet " +"配置。不过,对称性及其动力学后果属于架构属性,并不依赖规模。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:250 +#: ../../en/examples/reproductions/Neural_Mechanics.md:252 msgid "" "The three $\\lambda$ values were chosen for clear qualitative separation " "rather than to match the paper's specific settings." -msgstr "" -"这三个 $\\lambda$ 值旨在清晰区分定性行为,并非为匹配论文中的具体设置而选取。" +msgstr "这三个 $\\lambda$ 值旨在清晰区分定性行为,并非为匹配论文中的具体设置而选取。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:252 +#: ../../en/examples/reproductions/Neural_Mechanics.md:254 msgid "" -"Momentum and stochastic gradient noise -- both modeled in the paper's full " -"framework -- are not explored here." -msgstr "" -"本实验未探索动量和随机梯度噪声,尽管论文的完整框架对两者均有建模。" +"Momentum and stochastic gradient noise -- both modeled in the paper's " +"full framework -- are not explored here." +msgstr "本实验未探索动量和随机梯度噪声,尽管论文的完整框架对两者均有建模。" -#: ../../en/examples/reproductions/Neural_Mechanics.md:255 +#: ../../en/examples/reproductions/Neural_Mechanics.md:257 msgid "" -"This result should be read as **a fast qualitative demonstration** of how " -"finite learning rate and weight decay break the scale symmetry conservation " -"law, producing the centripetal-centrifugal competition described by Eq. 19." -msgstr "" -"应将该结果视为一次**快速的定性演示**:它展示了有限学习率和权重衰减如何破坏" -"尺度对称性的守恒律,并产生公式 19 所描述的向心—离心竞争。" +"This result should be read as **a fast qualitative demonstration** of how" +" finite learning rate and weight decay break the scale symmetry " +"conservation law, producing the centripetal-centrifugal competition " +"described by Eq. 19." +msgstr "应将该结果视为一次**快速的定性演示**:它展示了有限学习率和权重衰减如何破坏尺度对称性的守恒律,并产生公式 19 所描述的向心—离心竞争。" + diff --git a/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/rank-collapse-figure5.po b/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/rank-collapse-figure5.po index 6d7b8be..fa87e02 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/rank-collapse-figure5.po +++ b/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/rank-collapse-figure5.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-25 12:34+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: zh_CN\n" @@ -19,42 +19,42 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:7 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:8 msgid "Representation Compression and Information Flow · Phenomenon reproduction" msgstr "表征压缩与信息流 · 现象复现" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:10 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:11 msgid "Rank Collapse in a Linear Bottleneck" msgstr "线性瓶颈中的秩坍塌" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:13 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:14 msgid "" "Skewed class frequency plus a representation bottleneck can produce " "primacy bias, entropy expansion, and late-window compression - the three " "phases of the Rank Collapse trajectory." msgstr "类别频率偏斜与表征瓶颈叠加,会形成优先偏差、熵扩张和后期窗口压缩,即 Rank Collapse 轨迹的三个阶段。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:23 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:24 msgid "GS" msgstr "绍阳" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:28 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:29 msgid "Author" msgstr "作者" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:36 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:37 msgid "Scope" msgstr "范围" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:38 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:39 msgid "**Phenomenon reproduction (calibrated)**" msgstr "**现象复现(经校准)**" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Abstract" msgstr "摘要" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:43 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:44 #, python-brace-format msgid "" "This template reconstructs Figure 5 of the Rank Collapse paper on a six-" @@ -65,51 +65,54 @@ msgid "" "B/C/D report. This is a post-hoc deterministic figure reconstruction, not" " exact numerical recovery and not a seed-robustness claim." msgstr "" -"此模板在一个由六个样本组成的正交玩具问题上重建 Rank Collapse 论文的图 5。它在无偏置线性分解 `θ ∈ " -"R^{6×2}, F = Sθ, W ∈ R^{2×4}` 上运行完整的 softmax 交叉熵轨迹,其中 `d = 2 < |V| = 4`。画布直接呈现图 5D 的 " -"RankMe 曲线,并记录 `F`、`W` 以及两组特征值历史,用于 B/C/D 的报告。这是事后进行的确定性图形重建,不是精确的数值恢复,也不主张对随机 seed 稳健。" +"此模板在一个由六个样本组成的正交玩具问题上重建 Rank Collapse 论文的图 5。它在无偏置线性分解 `θ ∈ R^{6×2}, F = " +"Sθ, W ∈ R^{2×4}` 上运行完整的 softmax 交叉熵轨迹,其中 `d = 2 < |V| = 4`。画布直接呈现图 5D 的 " +"RankMe 曲线,并记录 `F`、`W` 以及两组特征值历史,用于 B/C/D " +"的报告。这是事后进行的确定性图形重建,不是精确的数值恢复,也不主张对随机 seed 稳健。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:52 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:53 msgid "" "**Paper:** [Tracing the Representation Geometry of Language Models from " "Pretraining to Post-training](https://arxiv.org/abs/2509.23024)" -msgstr "**论文:** [Tracing the Representation Geometry of Language Models from Pretraining to Post-training](https://arxiv.org/abs/2509.23024)" +msgstr "" +"**论文:** [Tracing the Representation Geometry of Language Models from " +"Pretraining to Post-training](https://arxiv.org/abs/2509.23024)" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:54 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:55 msgid "**Template:** `repro: Rank Collapse Figure 5 linear CE (calibrated)`" msgstr "**模板:** `repro: Rank Collapse Figure 5 linear CE (calibrated)`" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:56 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:57 msgid "**Template ID:** `repro-rank-collapse-figure5-linear-ce`" msgstr "**模板 ID:** `repro-rank-collapse-figure5-linear-ce`" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:58 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:59 msgid "Reproduction Goal" msgstr "复现目标" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:60 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:61 msgid "" "The paper claims that skewed class frequency together with a " "representation bottleneck can yield three intertwined phenomena:" msgstr "论文指出,类别频率偏斜与表征瓶颈共同作用,会产生三个彼此交织的现象:" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:63 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:64 msgid "**Primacy bias** - frequent classes are learned first." msgstr "**优先偏差**——出现频率高的类别会先被学会。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:64 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:65 msgid "" "**Selection bias** - the per-sample representation drift magnitude " "`|dσ_i/dt|` is proportional to the current singular value `σ_i`." msgstr "**选择偏差**——每个样本的表征漂移幅度 `|dσ_i/dt|` 与当前奇异值 `σ_i` 成正比。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:66 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:67 msgid "" "**Late-window compression** - after an initial entropy expansion, the " "effective rank (RankMe) compresses toward the bottleneck dimension `d`." msgstr "**后期窗口压缩**——在最初的熵扩张之后,有效秩(RankMe)向瓶颈维度 `d` 收缩。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:70 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:71 msgid "" "Figure 5 illustrates this on six inputs with labels `[0, 0, 1, 1, 2, 3]`," " giving class counts `[2, 2, 1, 1]`. Appendix B assumes orthogonal rows " @@ -119,205 +122,204 @@ msgstr "" "图 5 用六个输入说明这一点,其标签为 `[0, 0, 1, 1, 2, 3]`,类别计数为 `[2, 2, 1, 1]`。附录 B 假定行正交 " "`SSᵀ = I`;此模板选用规范坐标 `S = I₆`,且不设测试集划分。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:75 -msgid "Experiment Configuration" -msgstr "实验配置" +#: ../../en/examples/reproductions/rank-collapse-figure5.md:76 +msgid "" +"On this toy, the target observable is a three-stage RankMe trajectory " +"rather than loss decrease alone: an initial **warmup** in which the two-" +"dimensional representation stays close to its starting rank, an **entropy" +" expansion** in which frequent classes are learned first and the " +"representation briefly becomes more isotropic, and a **late-window " +"compression** in which the leading singular values separate and RankMe " +"decreases within the published 0-300 step window. This calibrated Figure-" +"5D-style trajectory is the phenomenon claimed by this template; it does " +"not claim exact numerical recovery, arbitrary-seed robustness, or " +"persistent/asymptotic collapse." +msgstr "" +"在这个玩具任务上,目标观测量是一条三阶段 RankMe " +"轨迹,而不仅仅是损失下降:初始的**预热**阶段中,二维表征保持接近其起始秩;**熵扩张**阶段中,高频类别先被学习,表征短暂变得更各向同性;**后期窗口压缩**阶段中,主导奇异值分离,RankMe" +" 在已发表的 0-300 步窗口内下降。这条经过校准的 Figure-5D " +"式轨迹就是本模板所声称的现象;它不声称精确数值复现、任意种子鲁棒性,或持续性/渐近性坍塌。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:77 -msgid "Paper vs. template settings" -msgstr "论文与模板设置" +#: ../../en/examples/reproductions/rank-collapse-figure5.md:87 +msgid "Paper Experiment and Reproduction Boundary" +msgstr "论文实验与复现边界" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:79 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:89 msgid "" "The paper does not disclose its learning rate, numerical initialization, " "seed, or toy source code. The template therefore locks a *calibrated* " "configuration that reproduces the published 0-300 step window." msgstr "论文没有披露学习率、数值初始化、seed 或该玩具问题的源代码。因此,模板固定采用一套*经校准的*配置,以重建已发表的 0–300 步区间。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Item" msgstr "项目" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Paper (Figure 5)" msgstr "论文(图 5)" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Template" msgstr "模板" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Dataset" msgstr "数据集" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "six orthogonal samples, labels `[0,0,1,1,2,3]`" msgstr "六个正交样本,标签为 `[0,0,1,1,2,3]`" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "`S = I₆`, same labels" msgstr "`S = I₆`,相同标签" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Class counts" msgstr "类别计数" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "`[2,2,1,1]` (skewed)" msgstr "`[2,2,1,1]`(偏斜)" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "same" msgstr "相同" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Model" msgstr "模型" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 #, python-brace-format msgid "`θ ∈ R^{6×2}`, `F = Sθ`, `W ∈ R^{2×4}`" msgstr "`θ ∈ R^{6×2}`、`F = Sθ`、`W ∈ R^{2×4}`" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "bias-free linear factorization, both Linear biases frozen at zero" msgstr "无偏置的线性分解;两个 Linear 层的偏置均冻结为零" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Bottleneck" msgstr "瓶颈" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "`d = 2 <" msgstr "`d = 2 < " -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "V" msgstr "V " -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Objective" msgstr "目标函数" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "full softmax cross-entropy" msgstr "完整 softmax 交叉熵" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Optimizer" msgstr "优化器" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "not disclosed" msgstr "未披露" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "SGD, `lr = 0.06`, `momentum = 0`, `weight decay = 0`" msgstr "SGD,`lr = 0.06`、`momentum = 0`、`weight decay = 0`" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Initialization" msgstr "初始化" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Theorem B.2 assumes `FᵀF = WWᵀ`" msgstr "定理 B.2 假定 `FᵀF = WWᵀ`" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "`rank_aligned_initialization`, balanced singular values `[1.56, 1.20]`" msgstr "`rank_aligned_initialization`,均衡奇异值 `[1.56, 1.20]`" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Seed" msgstr "seed" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "`25`" msgstr "`25`" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Training length" msgstr "训练长度" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "0-300 plotted updates" msgstr "绘图覆盖 0–300 次更新" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "`300` steps, full-batch (`batchSize = -1`)" msgstr "`300` 步,全批量(`batchSize = -1`)" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "Device" msgstr "设备" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:40 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:41 msgid "`cpu`" msgstr "`cpu`" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:96 -msgid "Node graph" -msgstr "节点图" - -#: ../../en/examples/reproductions/rank-collapse-figure5.md:111 -msgid "Key parameters" -msgstr "关键参数" - -#: ../../en/examples/reproductions/rank-collapse-figure5.md:113 -msgid "" -"**Dataset** (`paper_classification_dataset`): `experimentMode = " -"\"rank_figure5_main\"`, `inputDim = 6`, `outputDim = 4`, `trainSize = 6`," -" `testSize = 0`, `seed = 25`, `samplingMode = \"fixed\"`." -msgstr "" -"**数据集**(`paper_classification_dataset`):`experimentMode = " -"\"rank_figure5_main\"`、`inputDim = 6`、`outputDim = 4`、`trainSize = " -"6`、`testSize = 0`、`seed = 25`、`samplingMode = \"fixed\"`。" - -#: ../../en/examples/reproductions/rank-collapse-figure5.md:116 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:106 msgid "" -"**Model** (`mlp_model`): `depth = 1`, `width = 2`, `activation = " -"\"identity\"`, `outputScale = 1.0`, `seed = 25`." +"This reproduction checks whether the calibrated template reconstructs the" +" published 0-300 step RankMe trajectory. It does not provide arbitrary-" +"seed robustness, exact numeric recovery of the paper's curve, or a " +"persistent/asymptotic collapse claim, so its conclusions remain a post-" +"hoc deterministic figure reconstruction." msgstr "" -"**模型**(`mlp_model`):`depth = 1`、`width = 2`、`activation = " -"\"identity\"`、`outputScale = 1.0`、`seed = 25`。" +"本复现检查经过校准的模板是否重建了已发表的 0-300 步 RankMe " +"轨迹。它不提供任意种子鲁棒性、论文曲线的精确数值复现,也不主张持续性/渐近性坍塌,因此其结论仍是一次事后的确定性图形重建。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:118 -msgid "" -"**Initialization** (`rank_aligned_initialization`): `scale = 1.2`, " -"`singularRatio = 1.3`, `seed = 25`." -msgstr "" -"**初始化**(`rank_aligned_initialization`):`scale = 1.2`、`singularRatio = " -"1.3`、`seed = 25`。" +#: ../../en/examples/reproductions/rank-collapse-figure5.md:112 +msgid "Experiment Configuration" +msgstr "实验配置" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:120 -msgid "" -"**Optimizer** (`sgd_optimizer`): `learningRate = 0.06`, `momentum = 0.0`," -" `weightDecay = 0.0`." -msgstr "" -"**优化器**(`sgd_optimizer`):`learningRate = 0.06`、`momentum = " -"0.0`、`weightDecay = 0.0`。" +#: ../../en/examples/reproductions/rank-collapse-figure5.md:114 +msgid "Node graph" +msgstr "节点图" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:122 -msgid "" -"**Trainer**: `trainingSteps = 300`, `logFrequency = 1`, `batchSize = -1`," -" `computeDevice = \"cpu\"`, `gradClipMaxNorm = 0.0`." -msgstr "" -"**Trainer**:`trainingSteps = 300`、`logFrequency = 1`、`batchSize = " -"-1`、`computeDevice = \"cpu\"`、`gradClipMaxNorm = 0.0`。" +#: ../../en/examples/reproductions/rank-collapse-figure5.md:129 +msgid "Key parameters" +msgstr "关键参数" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:124 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:131 msgid "" -"**Observable** (`observable_representation_rankme`): `representationId = " -"\"0::output\"`, `captureTrajectories = true`." +"The table above already fixes every item the paper discloses (dataset, " +"class counts, model shape, bottleneck, objective); this template's own " +"choices only fill in what the paper leaves undisclosed - `mlp_model` " +"realizes the `d = 2` bottleneck as `depth = 1`, `width = 2`, `activation " +"= \"identity\"`; `rank_aligned_initialization` balances the singular " +"values via `scale = 1.2`, `singularRatio = 1.3`; and " +"`observable_representation_rankme` reads `representationId = " +"\"0::output\"` with `captureTrajectories = true` so the full RankMe path " +"can be plotted. The exact per-node values are locked by the Template's " +"Baseline Test." msgstr "" -"**Observable**(`observable_representation_rankme`):`representationId = " -"\"0::output\"`、`captureTrajectories = true`。" +"上表已经固定了论文披露的每一项(数据集、类别数、模型形状、瓶颈、目标函数);本模板自身的选择只填补论文未披露的部分:`mlp_model` 将 " +"`d = 2` 瓶颈实现为 `depth = 1`、`width = 2`、`activation = " +"\"identity\"`;`rank_aligned_initialization` 通过 `scale = " +"1.2`、`singularRatio = 1.3` 平衡奇异值;`observable_representation_rankme` 读取 " +"`representationId = \"0::output\"`,并设置 `captureTrajectories = true` " +"以便绘制完整的 RankMe 路径。各节点的精确取值由该 Template 的 Baseline Test 锁定。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:127 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:142 msgid "Run in Comfy Research" msgstr "在 Comfy Research 中运行" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:129 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:144 msgid "" "Open **Templates** and load `repro: Rank Collapse Figure 5 linear CE " "(calibrated)`." @@ -325,23 +327,23 @@ msgstr "" "打开 **Templates**,加载 `repro: Rank Collapse Figure 5 linear CE " "(calibrated)`。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:130 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:145 msgid "Click **Train** on the `Trainer` node." msgstr "在 `Trainer` 节点上点击 **Train**。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:131 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:146 msgid "" "Inspect the `Figure 5D · RankMe trajectory` panel for the three phases: " "warmup → entropy expansion → late-window compression." msgstr "在 `Figure 5D · RankMe trajectory` 面板中查看三个阶段:预热 → 熵扩张 → 后期窗口压缩。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:133 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:148 msgid "" "The `Full-training accuracy` panel confirms that the model still fits the" " skewed training set despite the rank collapse." msgstr "`Full-training accuracy` 面板显示,尽管发生秩坍塌,模型仍可拟合频率偏斜的训练集。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:136 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:151 msgid "" "The data is generated on the fly by `paper_classification_dataset` - no " "offline dataset file is needed. Changing `experimentMode`, `inputDim`, or" @@ -350,74 +352,89 @@ msgstr "" "数据由 `paper_classification_dataset` 即时生成,无需离线数据集文件。修改 " "`experimentMode`、`inputDim` 或 `trainSize` 会立即重新生成合成矩阵。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:140 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:155 msgid "Results" msgstr "结果" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:142 -msgid "Figure 5D: RankMe trajectory" -msgstr "图 5D:RankMe 轨迹" - -#: ../../en/examples/reproductions/rank-collapse-figure5.md:144 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:157 msgid "" -"The RankMe curve exhibits the paper's three-phase signature within the " -"0-300 step window:" -msgstr "在 0–300 步区间内,RankMe 曲线呈现出论文所述的三阶段特征:" +"The screenshot below comes from the real ComfyResearch UI: the calibrated" +" Figure 5 template after the Trainer reached `complete` at 300 steps, " +"with loss, accuracy, and the Figure 5D RankMe trajectory visible." +msgstr "" +"下方截图来自真实的 ComfyResearch UI:经过校准的 Figure 5 模板在 Trainer 于第 300 步到达 " +"`complete` 之后,损失、准确率和 Figure 5D 的 RankMe 轨迹均可见。" + +#: ../../en/examples/reproductions/rank-collapse-figure5.md:161 +msgid "ComfyResearch Rank Collapse Figure 5 Template after training completed" +msgstr "训练完成后的 ComfyResearch Rank Collapse Figure 5 模板" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:147 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:165 msgid "" -"**Warmup (≈ steps 0-40):** RankMe stays near its initial value, close to " -"the `d = 2` ceiling, because the softmax predictions are still near-" -"uniform." -msgstr "**预热(约第 0–40 步):** RankMe 维持在接近初始值、也接近 `d = 2` 上限的位置,因为 softmax 预测仍近似均匀。" +"Live ComfyResearch UI after the 300-step full-batch run. The " +"`Representation RankMe Viz` panel shows the warmup → expansion → " +"compression signature within the published 0-300 step window." +msgstr "" +"300 步全批量运行后的实时 ComfyResearch UI。`Representation RankMe Viz` 面板展示了已发表的 " +"0-300 步窗口内的预热 → 扩张 → 压缩特征。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:150 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:170 msgid "" -"**Entropy expansion (≈ steps 40-150):** as frequent classes are learned " -"first, the representation differentiates and RankMe rises briefly above " -"the initialization before the bottleneck re-asserts itself." -msgstr "**熵扩张(约第 40–150 步):** 高频类别先被学会,表征随之分化,RankMe 会短暂高于初始化值,之后瓶颈重新占据主导。" +"The calibrated full run exhibits the paper's three-phase signature within" +" the 0-300 step window:" +msgstr "经过校准的完整运行在 0-300 步窗口内展现出论文的三阶段特征:" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:154 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:173 msgid "" -"**Late-window compression (≈ steps 150-300):** the two leading singular " -"values separate and RankMe compresses toward `d = 2`." -msgstr "**后期窗口压缩(约第 150–300 步):** 前两个奇异值逐渐分离,RankMe 向 `d = 2` 收缩。" +"**Warmup (approximately steps 0-40):** RankMe moves from `1.9096807` to a" +" trough of `1.8919100` at step 33." +msgstr "**预热(大约第 0-40 步):** RankMe 从 `1.9096807` 移动到第 33 步的谷值 `1.8919100`。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:157 -msgid "Boundary of the reconstruction" -msgstr "重建边界" +#: ../../en/examples/reproductions/rank-collapse-figure5.md:175 +msgid "" +"**Entropy expansion (approximately steps 40-214):** RankMe rises to " +"`1.9931071` at step 214 as the representation differentiates." +msgstr "**熵扩张(大约第 40-214 步):** 随着表征分化,RankMe 在第 214 步升至 `1.9931071`。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:159 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:177 msgid "" -"The `0-300` published window is reconstructed; extending the *same* " -"configuration to `10,000` steps re-expands RankMe toward the `d = 2` " -"RankMe ceiling. **Persistent/asymptotic collapse is not claimed.**" +"**Late-window compression (steps 214-300):** RankMe falls to `1.9839966`;" +" the terminal slope is `-1.34984e-4` per step and the singular-value " +"ratio falls from `0.846641` to `0.775271`." msgstr "" -"这里重建的是已发表的 `0-300` 区间;将*同一*配置延长至 `10,000` 步后,RankMe 会重新向 `d = 2` 的 RankMe " -"上限扩张。**并不主张存在持续性或渐近性的坍塌。**" +"**后期窗口压缩(第 214-300 步):** RankMe 降至 `1.9839966`;终末斜率为每步 " +"`-1.34984e-4`,奇异值比从 `0.846641` 降至 `0.775271`。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:162 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:181 msgid "" -"Theorem B.2's aligned-initialization invariant `FᵀF = WWᵀ` is preserved " -"by construction; Theorem B.3's dominant-class-size bound additionally " -"assumes near-uniform initial predictions and `|V| ≫ 1`, so its " -"quantitative bound is not exact for this four-class toy." -msgstr "" -"定理 B.2 的对齐初始化不变量 `FᵀF = WWᵀ` 由构造保证;定理 B.3 的主导类别大小界还假定初始预测近似均匀且 `|V| ≫ " -"1`,因此其定量界对这个四类别玩具问题并不精确。" +"Extending the same configuration beyond the published window changes the " +"picture. The screenshot below keeps the same dataset, model, optimizer, " +"and initialization but runs for `1000` steps:" +msgstr "将同一配置延伸到已发表窗口之外会改变结果。下方截图保持相同的数据集、模型、优化器和初始化,但运行 `1000` 步:" + +#: ../../en/examples/reproductions/rank-collapse-figure5.md:185 +msgid "Figure 5 template extended to 1000 steps" +msgstr "延伸至 1000 步的 Figure 5 模板" + +#: ../../en/examples/reproductions/rank-collapse-figure5.md:189 +msgid "" +"Extending the run to 1000 steps: after the initial compression, RankMe " +"re-expands and approaches the `d = 2` ceiling. The finite-window collapse" +" signature is not persistent at this horizon." +msgstr "将运行延伸至 1000 步:初始压缩之后,RankMe 重新扩张并趋近 `d = 2` 上限。该有限窗口坍塌特征在此时间跨度上并不持续。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:166 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:194 msgid "" -"This is a post-hoc deterministic figure reconstruction, not exact " -"numerical recovery and not a random-seed robustness claim." -msgstr "这是事后进行的确定性图形重建,不是精确的数值恢复,也不主张对随机 seed 稳健。" +"The feature-Gram alignment error is `4.68749e-8` in the full evidence, " +"consistent with the balanced initialization protocol used by this " +"mechanism-level reconstruction." +msgstr "在完整证据中,特征 Gram 对齐误差为 `4.68749e-8`,与本机制层面重建所用的平衡初始化协议一致。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:169 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:198 msgid "Interpretation" msgstr "解读" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:171 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:200 msgid "" "The run qualitatively supports the paper's central mechanism: under " "skewed class frequency and a `d < |V|` bottleneck, full-batch softmax " @@ -427,42 +444,88 @@ msgid "" " selection-bias relation can be read off the two eigenvalue histories " "captured by the `observable_representation_rankme` node." msgstr "" -"这次运行从定性上支持论文的核心机制:在类别频率偏斜和 `d < |V|` 瓶颈的条件下,全批量 softmax 交叉熵会产生经历预热、扩张和末端压缩的 " -"RankMe 轨迹。模板明确给出无偏置的线性分解(`F = Sθ`,并直接使用 `W`),因此可根据 " +"这次运行从定性上支持论文的核心机制:在类别频率偏斜和 `d < |V|` 瓶颈的条件下,全批量 softmax " +"交叉熵会产生经历预热、扩张和末端压缩的 RankMe 轨迹。模板明确给出无偏置的线性分解(`F = Sθ`,并直接使用 `W`),因此可根据 " "`observable_representation_rankme` 节点记录的两组特征值历史,读出 `dσ_i/dt ∝ σ_i` " "这一选择偏差关系。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:180 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:209 msgid "Limitations" msgstr "局限性" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:182 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:211 msgid "Six-sample toy only; no natural corpus and no test split." msgstr "仅限于六样本玩具问题;不含自然语料,也没有测试集划分。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:183 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:212 msgid "" "Calibrated `lr = 0.06`, `seed = 25`, singular values `[1.56, 1.20]` are " "not paper-backed; they were chosen to reconstruct the published window." msgstr "经校准的 `lr = 0.06`、`seed = 25` 和奇异值 `[1.56, 1.20]` 均无论文依据;选择这些值是为了重建已发表的区间。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:186 -msgid "The late decline is finite-window; asymptotic collapse is not claimed." -msgstr "后期下降仅发生在有限窗口内;不主张渐近坍塌。" +#: ../../en/examples/reproductions/rank-collapse-figure5.md:215 +msgid "" +"The `0-300` published window is reconstructed. Extending the same " +"configuration to `10,000` steps re-expands RankMe toward the `d = 2` " +"ceiling; **persistent/asymptotic collapse is not claimed**. The late " +"decline is finite-window only." +msgstr "" +"已发表的 `0-300` 步窗口已被重建。将同一配置延伸到 `10,000` 步会使 RankMe 重新扩张并趋近 `d = 2` " +"上限;**不主张持续性/渐近性坍塌**。后期下降仅在有限窗口内成立。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:187 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:219 +msgid "" +"The calibrated run is a deterministic full-evidence result at seed 25. An" +" audit over seeds 0-99 passed the strict Figure-5D landmark contract for " +"`1/100` seeds, so this is not a random-seed robustness result." +msgstr "" +"该校准运行是种子 25 下的确定性完整证据结果。对种子 0-99 的审计中,只有 `1/100` 个种子通过了严格的 Figure-5D " +"关键点契约,因此这不是一个随机种子鲁棒性结果。" + +#: ../../en/examples/reproductions/rank-collapse-figure5.md:222 +msgid "" +"Uniform-class, no-bottleneck, and MSE controls do not show the same " +"negative terminal slope. They are controls for the disclosed protocol, " +"not proof of a universal causal effect." +msgstr "均匀类别、无瓶颈和 MSE 对照组都没有表现出相同的负终末斜率。它们是针对已披露协议的对照,而非普遍因果效应的证明。" + +#: ../../en/examples/reproductions/rank-collapse-figure5.md:225 +msgid "" +"Theorem B.2's aligned-initialization invariant `FᵀF = WWᵀ` is preserved " +"by construction; Theorem B.3's dominant-class-size bound additionally " +"assumes near-uniform initial predictions and `|V| ≫ 1`, so its " +"quantitative bound is not exact for this four-class toy." +msgstr "" +"定理 B.2 的对齐初始化不变量 `FᵀF = WWᵀ` 由构造保证;定理 B.3 的主导类别大小界还假定初始预测近似均匀且 `|V| ≫ " +"1`,因此其定量界对这个四类别玩具问题并不精确。" + +#: ../../en/examples/reproductions/rank-collapse-figure5.md:229 msgid "" "The class-wise primacy ordering (Figure 5B/5C) is not separately " "validated by this template - only the Panel-D RankMe signature." msgstr "此模板没有单独验证按类别划分的优先次序(图 5B/5C),仅验证 Panel-D 的 RankMe 特征。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:189 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:231 msgid "No Grokking task is used." msgstr "未使用 Grokking 任务。" -#: ../../en/examples/reproductions/rank-collapse-figure5.md:191 +#: ../../en/examples/reproductions/rank-collapse-figure5.md:232 +msgid "" +"The repository's Template tests lock the core nodes, connections, " +"parameters, and RankMe Observable in a baseline test; a separate in-" +"memory CI smoke test shortens the training budget and only checks that " +"the Trainer reaches `complete` with finite loss and Observable values - " +"it does not claim to reproduce the three phases. The full evidence above " +"is the 300-step CPU run that supports the phenomenon statement." +msgstr "" +"仓库的 Template 测试在一个 baseline 测试中锁定了核心节点、连接、参数和 RankMe Observable;另一个独立的内存中" +" CI 冒烟测试缩短了训练预算,只检查 Trainer 是否以有限的损失和 Observable 值到达 " +"`complete`,它不声称复现这三个阶段。上方的完整证据是支持该现象陈述的 300 步 CPU 运行。" + +#: ../../en/examples/reproductions/rank-collapse-figure5.md:240 msgid "" "These limitations mean the result should be read as a mechanism-level " "stress test, not a strict numeric replication or a universal causal " "claim." msgstr "这些局限意味着,结果应被视为机制层面的压力测试,而非严格的数值复现或普遍的因果主张。" + diff --git a/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/rank-collapse-tinyshakespeare.po b/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/rank-collapse-tinyshakespeare.po index ab85e58..b6f7ac5 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/rank-collapse-tinyshakespeare.po +++ b/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/rank-collapse-tinyshakespeare.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-25 12:34+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: zh_CN\n" @@ -19,44 +19,44 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:7 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:8 msgid "Representation Compression and Information Flow · Spectral audit" msgstr "表征压缩与信息流 · 谱审计" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:10 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:11 msgid "Rank Collapse on Real Text (TinyShakespeare)" msgstr "真实文本上的秩坍塌(TinyShakespeare)" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:13 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:14 msgid "" -"A small causal Transformer trained from scratch on real TinyShakespeare " -"text reveals the same warmup → expansion → compression RankMe signature " +"A small causal Transformer trained from scratch on real TinyShakespeare " +"text reveals the same warmup → expansion → compression RankMe signature " "that the paper measures on intermediate Pythia/OLMo checkpoints." msgstr "" "在真实 TinyShakespeare 文本上从头训练的小型因果 Transformer,呈现出与论文在中间 Pythia/OLMo " "checkpoint 中测得相同的预热 → 扩张 → 压缩 RankMe 特征。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:23 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:24 msgid "GS" msgstr "绍阳" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:28 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:29 msgid "Author" msgstr "作者" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:36 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:37 msgid "Scope" msgstr "范围" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:38 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:39 msgid "**Phenomenon reproduction (spectral audit)**" msgstr "**现象复现(谱审计)**" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "Abstract" msgstr "摘要" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:43 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:44 msgid "" "This template trains a small causal Transformer from scratch on real " "TinyShakespeare text so the entire rank-collapse pipeline remains " @@ -66,259 +66,208 @@ msgid "" "intermediate-checkpoint experiment on real pretraining corpora." msgstr "" "此模板在真实 TinyShakespeare 文本上从头训练一个小型因果 Transformer,让整个秩坍塌流程都能在 Comfy " -"Research 中编辑和运行。每次记录时,均在每个 token 位置的 final-hidden 表征上计算 RankMe 和 alphaReQ。" -"这是一次谱审计,不是论文基于真实 pretraining 语料进行的完整中间 checkpoint 实验。" +"Research 中编辑和运行。每次记录时,均在每个 token 位置的 final-hidden 表征上计算 RankMe 和 " +"alphaReQ。这是一次谱审计,不是论文基于真实 pretraining 语料进行的完整中间 checkpoint 实验。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:51 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:52 msgid "" "**Paper:** [Tracing the Representation Geometry of Language Models from " "Pretraining to Post-training](https://arxiv.org/abs/2509.23024)" -msgstr "**论文:** [Tracing the Representation Geometry of Language Models from Pretraining to Post-training](https://arxiv.org/abs/2509.23024)" +msgstr "" +"**论文:** [Tracing the Representation Geometry of Language Models from " +"Pretraining to Post-training](https://arxiv.org/abs/2509.23024)" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:53 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:54 msgid "**Template:** `repro: Rank Collapse TinyShakespeare spectral audit`" msgstr "**模板:** `repro: Rank Collapse TinyShakespeare spectral audit`" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:55 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:56 msgid "**Template ID:** `repro-rank-collapse-tinyshakespeare-pretraining`" msgstr "**模板 ID:** `repro-rank-collapse-tinyshakespeare-pretraining`" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:57 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:58 msgid "Reproduction Goal" msgstr "复现目标" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:59 -msgid "" -"The paper's main experiment measures RankMe and alphaReQ on last-token " -"hidden states of intermediate Pythia/OLMo checkpoints evaluated on " -"FineWeb. That protocol requires downloading multi-terabyte checkpoint " -"series and a real pretraining corpus, which is not feasible inside an " -"editable Comfy Research graph." -msgstr "" -"论文的主要实验是在 FineWeb 上评估中间 Pythia/OLMo checkpoint 的末 token 隐藏状态,并在其上测量 RankMe 和 " -"alphaReQ。该方案需要下载数 TB 的 checkpoint 序列和真实 pretraining 语料,无法在可编辑的 Comfy Research 图中实现。" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:65 -msgid "This template instead asks a narrower question:" -msgstr "此模板转而提出一个更窄的问题:" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:67 -msgid "" -"Does the same spectral signature (warmup → expansion → compression) " -"appear when a small causal Transformer is trained from scratch on real " -"text, with RankMe/alphaReQ measured on every token position of the final " -"hidden state?" -msgstr "" -"当一个小型因果 Transformer 在真实文本上从头训练,并在最终隐藏状态的每个 token 位置测量 RankMe/alphaReQ " -"时,是否会出现相同的谱特征(预热 → 扩张 → 压缩)?" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:72 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:60 msgid "" -"If yes, the mechanism is not an artifact of the paper's checkpoint " -"protocol; it is a property of the learning dynamics themselves." -msgstr "如果会出现,这一机制便不是论文 checkpoint 协议造成的产物,而是学习动态本身的属性。" +"Show that the final-hidden RankMe curve **rises, then falls** over " +"training on real TinyShakespeare text." +msgstr "证明在真实 TinyShakespeare 文本上训练时,final-hidden RankMe 曲线会**先升后降**。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:75 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:63 msgid "Experiment Configuration" msgstr "实验配置" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:77 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:65 msgid "Paper vs. template settings" msgstr "论文与模板设置" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "Item" msgstr "项目" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "Paper (main experiment)" msgstr "论文(主要实验)" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "Template (spectral audit)" msgstr "模板(谱审计)" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "Corpus" msgstr "语料" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "real pretraining corpora (FineWeb eval)" msgstr "真实 pretraining 语料(在 FineWeb 上评估)" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "real TinyShakespeare word-level corpus" msgstr "真实的 TinyShakespeare 词级语料" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "Model" msgstr "模型" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "Pythia / OLMo (intermediate checkpoints)" msgstr "Pythia / OLMo(中间 checkpoint)" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "small causal Transformer trained from scratch" msgstr "从头训练的小型因果 Transformer" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "Vocab" msgstr "词表" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "large subword BPE" msgstr "大型子词 BPE" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "corpus-derived top-256 words + PAD/BOS/EOS/UNK" msgstr "从语料中得到的前 256 个词 + PAD/BOS/EOS/UNK" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "Context" msgstr "上下文" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "long-context eval" msgstr "长上下文评估" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "32-token windows" msgstr "32-token 窗口" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "RankMe/alphaReQ" msgstr "RankMe/alphaReQ 指标" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "last-token hidden state" msgstr "末 token 隐藏状态" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "every token position as a sample from `lm_head::input`" msgstr "每个 token 位置都作为 `lm_head::input` 的一个样本" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "Horizon" msgstr "范围" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 msgid "full pretraining" msgstr "完整 pretraining" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:40 -msgid "100,000 steps (evidence horizon, not CI)" -msgstr "100,000 步(证据时域,而非 CI)" +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:41 +msgid "20,000 steps (evidence horizon, not CI)" +msgstr "20,000 步(证据时域,而非 CI)" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:88 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:76 msgid "Node graph" msgstr "节点图" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:102 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:90 msgid "Key parameters" msgstr "关键参数" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:104 -msgid "" -"**Dataset** (`tinyshakespeare_lm_dataset`): `vocabSize = 256`, " -"`contextLength = 32`, `trainSize = 4000`, `testSize = 0`, `seed = 0`, " -"`initSeed = 0`, `stride = 1`." -msgstr "" -"**数据集**(`tinyshakespeare_lm_dataset`):`vocabSize = 256`、`contextLength = " -"32`、`trainSize = 4000`、`testSize = 0`、`seed = 0`、`initSeed = 0`、`stride =" -" 1`。" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:107 -msgid "" -"**Model** (`transformer_token_model`): `vocabSize = 256`, `contextLength " -"= 32`, `modelDim = 16`, `numHeads = 4`, `numLayers = 3`, `ffDim = 64`, " -"`activation = \"gelu\"`, `encoderBackend = \"stable\"`, `encoderDropout =" -" 0.0`, `tieEmbeddingLmHead = \"yes\"`, `causalAttention = \"yes\"`, `seed" -" = 0`." -msgstr "" -"**模型**(`transformer_token_model`):`vocabSize = 256`、`contextLength = " -"32`、`modelDim = 16`、`numHeads = 4`、`numLayers = 3`、`ffDim = " -"64`、`activation = \"gelu\"`、`encoderBackend = \"stable\"`、`encoderDropout" -" = 0.0`、`tieEmbeddingLmHead = \"yes\"`、`causalAttention = \"yes\"`、`seed " -"= 0`。" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:112 -msgid "" -"**Optimizer** (`adamw_optimizer`): `learningRate = 0.002`, `beta1 = 0.9`," -" `beta2 = 0.999`, `epsilon = 1e-8`, `weightDecay = 0.01`." -msgstr "" -"**优化器**(`adamw_optimizer`):`learningRate = 0.002`、`beta1 = 0.9`、`beta2 = " -"0.999`、`epsilon = 1e-8`、`weightDecay = 0.01`。" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:115 -msgid "" -"**Loss** (`cross_entropy_loss`): `lossScale = 1.0`, `labelSmoothing = " -"0.0`." -msgstr "**损失**(`cross_entropy_loss`):`lossScale = 1.0`、`labelSmoothing = 0.0`。" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:116 -msgid "" -"**Trainer**: `trainingSteps = 100000`, `logFrequency = 500`, `batchSize =" -" 32`, `computeDevice = \"cpu\"`, `gradClipMaxNorm = 1.0`." -msgstr "" -"**Trainer**:`trainingSteps = 100000`、`logFrequency = 500`、`batchSize = " -"32`、`computeDevice = \"cpu\"`、`gradClipMaxNorm = 1.0`。" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:118 -msgid "**Observables**:" -msgstr "**Observables**:" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:119 -msgid "" -"`observable_representation_rankme`: `representationId = " -"\"lm_head::input\"`, `tokenPositionsAsSamples = true`, " -"`captureTrajectories = false`." +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:92 +msgid "" +"The paper's model, corpus, and horizon are not directly portable to a " +"from-scratch CI-friendly run, so this template scales every axis down at " +"once: a `modelDim = 4`, 4-head, 4-layer causal Transformer (paper: Pythia" +"/OLMo-scale checkpoints) over a `contextLength = 32` window on the real " +"TinyShakespeare corpus (paper: FineWeb eval), trained for `20,000` steps " +"(paper: full pretraining) with AdamW `learningRate = 0.005`, `beta2 = " +"0.99`, `weightDecay = 0.001`, and `batchSize = 32`. RankMe and alphaReQ " +"are both read from `lm_head::input`, sampling every token position rather" +" than only the last one. The exact per-node values (including `numHeads`," +" `ffDim`, `epsilon`, and the observable flags) are locked by the " +"Template's Baseline Test." msgstr "" -"`observable_representation_rankme`:`representationId = " -"\"lm_head::input\"`、`tokenPositionsAsSamples = true`、`captureTrajectories" -" = false`。" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:122 -msgid "" -"`observable_representation_alpha_req`: `representationId = " -"\"lm_head::input\"`, `tokenPositionsAsSamples = true`." -msgstr "" -"`observable_representation_alpha_req`:`representationId = " -"\"lm_head::input\"`、`tokenPositionsAsSamples = true`。" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:125 +"论文的模型、语料和时域无法直接搬到一次从头开始且对 CI 友好的运行上,因此本模板一次性缩小了每个维度:在真实 TinyShakespeare " +"语料(论文:FineWeb eval)上,用一个 `modelDim = 4`、4 头、4 层的因果 " +"Transformer(论文:Pythia/OLMo 规模的 checkpoint),在 `contextLength = 32` 窗口上,用 " +"AdamW(`learningRate = 0.005`、`beta2 = 0.99`、`weightDecay = " +"0.001`、`batchSize = 32`)训练 `20,000` 步(论文:完整预训练)。RankMe 和 alphaReQ 都从 " +"`lm_head::input` 读取,采样每个 token 位置,而不仅仅是最后一个。各节点的精确取值(包括 " +"`numHeads`、`ffDim`、`epsilon` 和 observable 标志)由该 Template 的 Baseline Test " +"锁定。" + +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:105 msgid "Run in Comfy Research" msgstr "在 Comfy Research 中运行" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:127 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:107 msgid "" "Open **Templates** and load `repro: Rank Collapse TinyShakespeare " "spectral audit`." msgstr "打开 **Templates**,加载 `repro: Rank Collapse TinyShakespeare spectral audit`。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:128 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:108 msgid "" "Click **Train** on the `Trainer` node. The first run will download the " "TinyShakespeare corpus if it is not already cached locally." msgstr "在 `Trainer` 节点上点击 **Train**。如果本地尚未缓存,首次运行会下载 TinyShakespeare 语料。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:130 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:110 msgid "" "Inspect the `Final-hidden RankMe` panel for the warmup → expansion → " -"compression signature over the 100,000-step horizon." -msgstr "在 `Final-hidden RankMe` 面板中查看 100,000 步证据时域内的预热 → 扩张 → 压缩特征。" +"compression signature over the 20,000-step horizon." +msgstr "在 `Final-hidden RankMe` 面板中查看 20,000 步时域内的预热 → 扩张 → 压缩特征。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:132 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:112 msgid "" "Cross-check `Final-hidden alphaReQ` for the power-law tail exponent; it " "should track the RankMe curve inversely during compression." msgstr "对照查看 `Final-hidden alphaReQ` 的幂律尾部指数;在压缩期间,它应与 RankMe 曲线反向变化。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:135 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:116 +msgid "Hyperparameter sensitivity: weight decay" +msgstr "超参数敏感性:weight decay" + +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:118 +msgid "" +"The template default uses `learningRate = 0.005` and `weightDecay = " +"0.001`. At `weightDecay = 0.01` the collapse signature is suppressed: the" +" decay flattens the narrow `d_model = 4` spectrum before the transient " +"RankMe expansion can form, so warmup → expansion → compression never " +"separates from noise. This makes the optimizer setting part of the " +"phenomenon boundary, not an arbitrary default." +msgstr "" +"模板默认使用 `learningRate = 0.005` 和 `weightDecay = 0.001`。在 `weightDecay = " +"0.01` 下坍塌特征会被抑制:衰减会在瞬态 RankMe 扩张形成之前压平狭窄的 `d_model = 4` 谱,因此预热 → 扩张 → " +"压缩永远无法从噪声中分离出来。这使得优化器设置成为该现象边界的一部分,而不是一个任意的默认值。" + +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:125 msgid "Data is downloaded, not synthesized" msgstr "数据下载而来,并非合成" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:137 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:127 msgid "" "The `tinyshakespeare_lm_dataset` node downloads the real TinyShakespeare " "corpus. **Download failure is an error, never a synthetic fallback** - " @@ -327,78 +276,80 @@ msgstr "" "`tinyshakespeare_lm_dataset` 节点会下载真实 TinyShakespeare " "语料。**下载失败就是错误,绝不回退到合成数据**——这保证了本审计中“真实文本”的主张。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:141 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:131 msgid "Results" msgstr "结果" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:143 -msgid "RankMe trajectory" -msgstr "RankMe 轨迹" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:145 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:133 msgid "" -"Over the 100,000-step horizon the RankMe curve exhibits the three-phase " -"signature:" -msgstr "在 100,000 步证据时域内,RankMe 曲线呈现出三阶段特征:" +"The screenshot below comes from the real ComfyResearch UI: the template-" +"default `modelDim = 4` graph after training, with live RankMe / alphaReQ " +"/ loss panels visible." +msgstr "" +"下方截图来自真实的 ComfyResearch UI:模板默认的 `modelDim = 4` 图在训练后的样子,实时 RankMe / " +"alphaReQ / loss 面板均可见。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:148 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:137 +msgid "ComfyResearch canvas after the rank-collapse training run completed" +msgstr "rank-collapse 训练运行完成后的 ComfyResearch 画布" + +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:141 msgid "" -"**Warmup (early steps):** RankMe starts near the initialization ceiling " -"because the tied embedding + LM head still produce near-uniform logits." +"Live ComfyResearch UI after training: optimizer (`lr=0.005`, " +"`weightDecay=0.001`), Trainer (`100000` steps in this run), and the " +"RankMe / alphaReQ / Training Viz panels showing the collapse signature." msgstr "" -"**预热(训练早期):** RankMe 起始时接近初始化上限,因为绑定的 embedding 与 LM head 仍会产生近似均匀的 " -"logits。" +"训练后的实时 ComfyResearch UI:优化器(`lr=0.005`、`weightDecay=0.001`)、Trainer(本次运行 " +"`100000` 步),以及展示坍塌特征的 RankMe / alphaReQ / Training Viz 面板。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:151 -msgid "" -"**Entropy expansion (mid training):** as the model begins to fit real-" -"word co-occurrence, the final-hidden representation differentiates and " -"RankMe rises." -msgstr "**熵扩张(训练中期):** 模型开始拟合真实词语的共现关系时,final-hidden 表征随之分化,RankMe 上升。" +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:146 +msgid "Tuning the bottleneck" +msgstr "调节瓶颈" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:154 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:148 msgid "" -"**Terminal compression (late training):** the leading singular values " -"separate and RankMe compresses toward the bottleneck imposed by `modelDim" -" = 16` and the tied embedding." -msgstr "**末端压缩(训练后期):** 前导奇异值逐渐分离,RankMe 向 `modelDim = 16` 和绑定 embedding 所施加的瓶颈收缩。" +"**`modelDim`** sets the width of the final-hidden bottleneck that RankMe " +"measures. Everything else (dataset, steps, optimizer) is held fixed at " +"the template defaults; only `modelDim` (and the matching `numHeads`) is " +"overridden. Smaller `modelDim` gives a tighter bottleneck, and the " +"compression phase is easier to see and holds up longer. Try `2`, `4`, and" +" `8` yourself in the Trainer's `modelDim` field and watch how the RankMe " +"trajectory changes." +msgstr "" +"**`modelDim`** 设置 RankMe 所测量的 final-hidden " +"瓶颈宽度。其他一切(数据集、步数、优化器)都固定在模板默认值;只有 `modelDim`(以及配套的 `numHeads`)会被覆盖。更小的 " +"`modelDim` 会带来更紧的瓶颈,压缩阶段更容易观察,且持续更久。可以自行在 Trainer 的 `modelDim` 字段中尝试 " +"`2`、`4` 和 `8`,观察 RankMe 轨迹如何变化。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:158 -msgid "alphaReQ" -msgstr "alphaReQ 指数" +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:156 +msgid "" +"If the phenomenon doesn't show up, shrink `modelDim` or lower " +"`weightDecay` -- a bottleneck that's too wide or too strongly regularized" +" hides the compression phase within any practical step budget." +msgstr "如果该现象没有出现,缩小 `modelDim` 或降低 `weightDecay`:过宽或正则化过强的瓶颈会在任何实际步数预算内掩盖压缩阶段。" #: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:160 msgid "" -"The alphaReQ exponent (power-law tail index of the singular-value " -"spectrum) moves inversely to RankMe during compression: as RankMe drops, " -"alphaReQ rises, confirming that the spectrum steepens - exactly the " -"signature the paper reports on real pretraining checkpoints." +"The screenshot below is a real negative example: `modelDim = 8` under the" +" same final settings (`lr=0.005`, `weightDecay=0.001`, `100000` steps in " +"this run). The RankMe curve dips briefly around the early steps, but the " +"bottleneck is too wide to sustain compression; the rank quickly returns " +"to a high value and stays there, confirming that the lack of collapse is " +"not a hyperparameter mistake but a width effect." msgstr "" -"压缩期间,alphaReQ 指数(奇异值谱的幂律尾部指数)与 RankMe 反向变化:RankMe 下降时,alphaReQ " -"上升,表明谱变得更陡——这正是论文在真实 pretraining checkpoint 中报告的特征。" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:165 -msgid "Boundary of the audit" -msgstr "审计边界" +"下方截图是一个真实的反例:在相同的最终设置(`lr=0.005`、`weightDecay=0.001`,本次运行 `100000` 步)下使用 " +"`modelDim = 8`。RankMe " +"曲线在早期步数附近短暂下探,但瓶颈太宽,无法维持压缩;秩很快回到较高值并停留在那里,证实没有出现坍塌并非超参数设置错误,而是宽度效应。" #: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:167 -msgid "" -"This is a spectral audit of a *trained-from-scratch* small model, not the" -" paper's intermediate-checkpoint protocol on Pythia/OLMo." -msgstr "这是对一个*从头训练*的小型模型进行的谱审计,而非论文针对 Pythia/OLMo 的中间 checkpoint 协议。" - -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:169 -msgid "" -"The 100,000-step default is an **evidence horizon, not CI**. The smoke " -"test shortens only an in-memory copy of the template and does not assert " -"the phenomenon." -msgstr "默认的 100,000 步是**证据时域,而非 CI**。冒烟测试只会缩短模板的内存副本,不会对该现象作断言。" +msgid "TinyShakespeare modelDim=8 results showing no sustained rank collapse" +msgstr "TinyShakespeare modelDim=8 结果,未出现持续的秩坍塌" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:172 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:171 msgid "" -"No prior exploratory curve is inherited as PASS; download failure is an " -"error rather than a synthetic fallback." -msgstr "不会把既有的探索曲线直接作为 PASS 继承;下载失败就是错误,不会回退到合成数据。" +"`modelDim = 8` real-text run: a transient early dip is erased by the wide" +" bottleneck, so the collapse signature does not persist." +msgstr "`modelDim = 8` 真实文本运行:早期的瞬态下探被宽瓶颈抹平,因此坍塌特征不会持续。" #: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:175 msgid "Interpretation" @@ -414,46 +365,83 @@ msgid "" "dynamics under cross-entropy on real language, not an artifact of the " "checkpoint-sampling protocol." msgstr "" -"这次运行从定性上支持论文在真实文本上的核心机制:即使因果 Transformer 是从头训练的(而非在多个 pretraining checkpoint 上测量)," -"其 final-hidden 表征也呈现相同的预热 → 扩张 → 压缩 RankMe " +"这次运行从定性上支持论文在真实文本上的核心机制:即使因果 Transformer 是从头训练的(而非在多个 pretraining " +"checkpoint 上测量),其 final-hidden 表征也呈现相同的预热 → 扩张 → 压缩 RankMe " "特征。这表明,在真实语言上采用交叉熵时,该现象源于学习动态本身,而非 checkpoint 采样协议。" #: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:185 +msgid "" +"Step budget also matters, not just bottleneck width. At the template " +"default (`modelDim = 4`), RankMe does not stay at its post-compression " +"trough within the first 20,000 steps: after dipping to `1.3271` at step " +"400 it recovers to `2.3789` by step 20,000. Extending the same run to " +"100,000 steps shows the rank continuing to drift downward, reaching " +"`2.0396` at step 100,000 with a near-flat terminal slope (`-6.4e-7` per " +"step over the last 5,000 steps). The \"partial collapse\" label therefore" +" depends on horizon: the early trough is sharp and real, but full " +"equilibration happens slowly and the rank does not instantly plateau at " +"the trough. A narrower bottleneck (`modelDim = 2`) suppresses this slow " +"dynamics and stays collapsed through 20,000 steps, while a wide enough " +"bottleneck (`modelDim = 8`) never sustains compression at this horizon." +msgstr "" +"步数预算同样重要,不仅仅是瓶颈宽度。在模板默认设置(`modelDim = 4`)下,RankMe 在前 20,000 " +"步内并不会停留在压缩后的谷值:第 400 步下探到 `1.3271` 后,到第 20,000 步已回升至 `2.3789`。将同一运行延伸到 " +"100,000 步,可以看到秩继续向下漂移,在第 100,000 步达到 `2.0396`,终末斜率接近平坦(最后 5,000 步中每步 " +"`-6.4e-7`)。因此“部分坍塌”这一标签取决于时间跨度:早期的谷值又急又真实,但完全平衡的过程很缓慢,秩不会立即在谷值处停滞。更窄的瓶颈(`modelDim" +" = 2`)会抑制这种缓慢动力学,并在 20,000 步内保持坍塌状态,而足够宽的瓶颈(`modelDim = 8`)在此时间跨度上从不维持压缩。" + +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:199 msgid "Limitations" msgstr "局限性" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:187 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:201 msgid "" -"Small model (`d = 16`, 3 layers, 4 heads) on a tiny corpus " -"(TinyShakespeare); this is not a claim about large-scale pretraining." -msgstr "小型模型(`d = 16`、3 层、4 个 heads)在小型语料 TinyShakespeare 上运行;这并不主张适用于大规模 pretraining。" +"Small model (`d = 4`, 4 layers, 4 heads) on a tiny corpus " +"(TinyShakespeare), trained from scratch rather than measured across " +"Pythia/OLMo pretraining checkpoints; this is not a claim about large-" +"scale pretraining." +msgstr "" +"小型模型(`d = 4`、4 层、4 个 head)在小型语料(TinyShakespeare)上从头训练,而非在 Pythia/OLMo 预训练" +" checkpoint 之间测量;这并不主张适用于大规模预训练。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:189 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:205 msgid "" "The audit measures every token position as a sample from " "`lm_head::input`, whereas the paper evaluates last-token hidden states on" " FineWeb." msgstr "审计将每个 token 位置视为 `lm_head::input` 的样本来测量,而论文是在 FineWeb 上评估末 token 隐藏状态。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:192 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:208 msgid "" -"100,000 steps is an evidence horizon; the smoke test does not assert the " -"phenomenon and CI only verifies the pipeline runs." -msgstr "100,000 步是证据时域;冒烟测试不对该现象作断言,CI 仅验证流程能够运行。" +"20,000 steps is the default evidence horizon, not CI; the smoke test only" +" shortens an in-memory copy of the template to verify the pipeline runs, " +"it does not assert the phenomenon. A 100,000-step continuation was run " +"for `modelDim = 4` to check long-horizon behavior." +msgstr "" +"默认的 20,000 步是**证据时域,而非 " +"CI**;冒烟测试只会缩短模板的内存副本以验证流水线可运行,不会对该现象作断言。为检查长时域行为,还针对 `modelDim = 4` 运行了一次" +" 100,000 步的延续实验。" + +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:212 +msgid "" +"Download failure of the TinyShakespeare corpus is an error, never a " +"synthetic fallback." +msgstr "TinyShakespeare 语料下载失败是一个错误,绝不会退化为合成数据。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:194 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:214 msgid "" "No test split is used; the audit is about representation geometry, not " "generalization." msgstr "不使用测试集划分;本审计关注表征几何,而非泛化。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:196 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:216 msgid "No Grokking task is used." msgstr "未使用 Grokking 任务。" -#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:198 +#: ../../en/examples/reproductions/rank-collapse-tinyshakespeare.md:218 msgid "" "These limitations mean the result should be read as a small-scale " "spectral audit that corroborates the mechanism, not as a replication of " "the paper's full main experiment." msgstr "这些局限意味着,应将结果视为印证该机制的小规模谱审计,而非论文完整主要实验的复现。" + diff --git a/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/saad_solla_plateau_reproduction.po b/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/saad_solla_plateau_reproduction.po index 8c7865e..4971566 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/saad_solla_plateau_reproduction.po +++ b/docs/locales/zh_CN/LC_MESSAGES/examples/reproductions/saad_solla_plateau_reproduction.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-30 17:56+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: 2026-07-30 00:00+0800\n" "Last-Translator: Comfy Research Contributors\n" "Language: zh_CN\n" @@ -19,381 +19,366 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:7 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:9 msgid "Learning Phases and Feature Formation · Phenomenon reproduction" msgstr "学习阶段与特征形成:现象复现" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:10 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:12 msgid "Exact Solution for On-Line Learning in Multilayer Neural Networks" msgstr "多层神经网络在线学习的精确解" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:13 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:15 msgid "" "On-line gradient-descent learning in a teacher-student soft-committee " -"machine exhibits a plateau in the generalization error when the student has " -"more hidden nodes than the teacher ($K > M$), before a transition onto " -"specialization eliminates the unnecessary node." +"machine exhibits a plateau in the generalization error when the student " +"has more hidden nodes than the teacher ($K > M$), before a transition " +"onto specialization eliminates the unnecessary node." msgstr "" -"在 teacher-student 软委员会机中,当学生的隐藏节点多于教师($K > M$)时,在线" -"梯度下降学习的泛化误差会出现平台期;随后进入专化阶段,消除多余节点。" +"在 teacher-student 软委员会机中,当学生的隐藏节点多于教师($K > " +"M$)时,在线梯度下降学习的泛化误差会出现平台期;随后进入专化阶段,消除多余节点。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:24 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:26 msgid "ZH" msgstr "浩然" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:29 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:31 msgid "Author" msgstr "作者" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:37 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:39 msgid "Scope" msgstr "适用范围" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:39 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 msgid "**Phenomenon reproduction**" msgstr "**现象复现**" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Abstract" msgstr "摘要" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:44 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:46 msgid "" "On-line gradient-descent learning in a teacher-student soft-committee " -"machine exhibits trapping in a symmetric subspace when the student has more " -"hidden nodes than the teacher ($K > M$). The generalization error exhibits a" -" plateau -- remaining nearly constant over an extended range of the " -"normalized number of examples $\\alpha$ -- before a transition onto " -"specialization, after which the unnecessary student node is eliminated and " -"the generalization error drops toward zero. This is a qualitative " -"reproduction of the over-realizable learning dynamics reported in Saad & " -"Solla (1995)." -msgstr "" -"在 teacher-student 软委员会机中,当学生的隐藏节点多于教师($K > M$)时,在线" -"梯度下降学习会陷入对称子空间。泛化误差会出现平台期——在归一化样本数 $\\alpha$" -" 的较长区间内几乎保持不变——随后进入专化阶段;此后,多余的学生节点被消除,泛化" -"误差逐渐降至零。这是对 Saad & Solla(1995)所报道的过可实现学习动力学的定性" -"复现。" - -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:54 +"machine exhibits trapping in a symmetric subspace when the student has " +"more hidden nodes than the teacher ($K > M$). The generalization error " +"exhibits a plateau -- remaining nearly constant over an extended range " +"of the normalized number of examples $\\alpha$ -- before a transition " +"onto specialization, after which the unnecessary student node is " +"eliminated and the generalization error drops toward zero. This is a " +"qualitative reproduction of the over-realizable learning dynamics " +"reported in Saad & Solla (1995)." +msgstr "" +"在 teacher-student 软委员会机中,当学生的隐藏节点多于教师($K > " +"M$)时,在线梯度下降学习会陷入对称子空间。泛化误差会出现平台期——在归一化样本数 $\\alpha$ " +"的较长区间内几乎保持不变——随后进入专化阶段;此后,多余的学生节点被消除,泛化误差逐渐降至零。这是对 Saad & " +"Solla(1995)所报道的过可实现学习动力学的定性复现。" + +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:56 msgid "" "**Paper:** [Exact Solution for On-Line Learning in Multilayer Neural " "Networks](https://journals.aps.org/prl/abstract/10.1103/PhysRevLett.74.4337)," " Saad & Solla, Phys. Rev. Lett. 74, 4337-4340 (1995)." msgstr "" -"**论文:** [多层神经网络在线学习的精确解](https://journals.aps.org/prl/abstract/" -"10.1103/PhysRevLett.74.4337),Saad & Solla,Phys. Rev. Lett. 74, 4337-4340 " -"(1995)。" +"**论文:** " +"[多层神经网络在线学习的精确解](https://journals.aps.org/prl/abstract/10.1103/PhysRevLett.74.4337),Saad" +" & Solla,Phys. Rev. Lett. 74, 4337-4340 (1995)。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:58 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:60 msgid "**Template:** `Saad & Solla Over-Realizable (CPU)`" msgstr "**模板:** `Saad & Solla Over-Realizable (CPU)`" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:60 -msgid "**Template ID:** `saad-solla-over-realizable-cpu`" -msgstr "**模板 ID:** `saad-solla-over-realizable-cpu`" - #: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:62 +msgid "**Template ID:** `e399fd7d-e107-44d0-94b6-7e2159392253`" +msgstr "**模板 ID:** `e399fd7d-e107-44d0-94b6-7e2159392253`" + +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:64 msgid "Reproduction Goal" msgstr "复现目标" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:64 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:66 #, python-brace-format msgid "" "Saad & Solla (1995) derived coupled first-order differential equations " -"describing the dynamics of on-line gradient-descent learning in a two-layer " -"soft-committee machine in the thermodynamic limit $N \\to \\infty$. In the " -"**over-realizable** case ($K > M$, more student hidden nodes than teacher " -"hidden nodes), the order parameters $Q_{ik}$ and $R_{in}$ evolve through the" -" following stages:" +"describing the dynamics of on-line gradient-descent learning in a two-" +"layer soft-committee machine in the thermodynamic limit $N \\to \\infty$." +" In the **over-realizable** case ($K > M$, more student hidden nodes than" +" teacher hidden nodes), the order parameters $Q_{ik}$ and $R_{in}$ evolve" +" through the following stages:" msgstr "" -"Saad & Solla(1995)在热力学极限 $N \\to \\infty$ 下推导出一组耦合的一阶" -"微分方程,描述双层软委员会机的在线梯度下降学习动力学。在**过可实现**情形($K > " -"M$,学生隐藏节点多于教师隐藏节点)下,序参量 $Q_{ik}$ 与 $R_{in}$ 依次经历" -"以下阶段:" +"Saad & Solla(1995)在热力学极限 $N \\to \\infty$ " +"下推导出一组耦合的一阶微分方程,描述双层软委员会机的在线梯度下降学习动力学。在**过可实现**情形($K > " +"M$,学生隐藏节点多于教师隐藏节点)下,序参量 $Q_{ik}$ 与 $R_{in}$ 依次经历以下阶段:" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:71 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:73 msgid "" -"**Undifferentiated symmetric solution** -- all student weight vectors grow " -"with no differentiation among hidden nodes; the generalization error " -"decreases rapidly at first, then stalls." -msgstr "" -"**未分化对称解**——所有学生权重向量同步增长,隐藏节点之间尚未分化;泛化误差" -"起初迅速下降,随后停滞。" +"**Undifferentiated symmetric solution** -- all student weight vectors " +"grow with no differentiation among hidden nodes; the generalization error" +" decreases rapidly at first, then stalls." +msgstr "**未分化对称解**——所有学生权重向量同步增长,隐藏节点之间尚未分化;泛化误差起初迅速下降,随后停滞。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:74 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:76 msgid "" -"**Trapping in the symmetric subspace (plateau)** -- the student remains " -"trapped near a symmetric fixed point of the dynamical equations. The " +"**Trapping in the symmetric subspace (plateau)** -- the student remains" +" trapped near a symmetric fixed point of the dynamical equations. The " "generalization error exhibits a plateau, staying nearly constant over an " "extended interval of the normalized number of examples $\\alpha = P/N$." msgstr "" -"**陷入对称子空间(平台期)**——学生始终停留在动力学方程的对称不动点附近。泛化" -"误差出现平台期,在归一化样本数 $\\alpha = P/N$ 的较长区间内几乎保持不变。" +"**陷入对称子空间(平台期)**——学生始终停留在动力学方程的对称不动点附近。泛化误差出现平台期,在归一化样本数 $\\alpha = P/N$ " +"的较长区间内几乎保持不变。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:78 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:80 msgid "" "**Transition onto specialization** -- the symmetric solution becomes " "unstable; student hidden nodes begin to specialize, each aligning to a " "different teacher node." -msgstr "" -"**进入专化阶段**——对称解失去稳定性;学生隐藏节点开始专化,分别与不同的教师" -"节点对齐。" +msgstr "**进入专化阶段**——对称解失去稳定性;学生隐藏节点开始专化,分别与不同的教师节点对齐。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:81 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:83 #, python-brace-format msgid "" -"**Elimination of unnecessary nodes** -- the redundant ($K - M$) student node " -"decays toward zero norm ($Q_{33} \\to 0$)." -msgstr "" -"**消除多余节点**——冗余的($K - M$ 个)学生节点的范数衰减至零($Q_{33} \\to " -"0$)。" +"**Elimination of unnecessary nodes** -- the redundant ($K - M$) student" +" node decays toward zero norm ($Q_{33} \\to 0$)." +msgstr "**消除多余节点**——冗余的($K - M$ 个)学生节点的范数衰减至零($Q_{33} \\to 0$)。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:83 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:85 msgid "" -"**Perfect generalization** -- the surviving student nodes perfectly match the" -" teacher nodes, and the generalization error $\\to 0$." -msgstr "" -"**完美泛化**——保留下来的学生节点与教师节点完全匹配,泛化误差 $\\to 0$。" +"**Perfect generalization** -- the surviving student nodes perfectly " +"match the teacher nodes, and the generalization error $\\to 0$." +msgstr "**完美泛化**——保留下来的学生节点与教师节点完全匹配,泛化误差 $\\to 0$。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:86 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:88 #, python-brace-format msgid "" -"The reproduction graph records the test error ($\\frac{1}{2}$ × MSE) over " -"the normalized number of examples $\\alpha$, using tanh activations with the" -" paper's overlap-aware initialization and fixed $+1$ readout couplings." +"The reproduction graph records the test error ($\\frac{1}{2}$ × MSE) over" +" the normalized number of examples $\\alpha$, using tanh activations with" +" the paper's overlap-aware initialization and fixed $+1$ readout " +"couplings." msgstr "" -"该复现图记录测试误差($\\frac{1}{2}$ × MSE)随归一化样本数 $\\alpha$ 的变化," -"使用 tanh 激活、论文中的重叠感知初始化,以及固定为 $+1$ 的读出耦合。" +"该复现图记录测试误差($\\frac{1}{2}$ × MSE)随归一化样本数 $\\alpha$ 的变化,使用 tanh " +"激活、论文中的重叠感知初始化,以及固定为 $+1$ 的读出耦合。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:90 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:92 msgid "Paper Experiment and Reproduction Boundary" msgstr "论文实验与复现边界" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Item" msgstr "项目" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Paper" msgstr "论文" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Current Comfy Research template" msgstr "当前 Comfy Research 模板" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Claim" msgstr "主张" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "" "Over-realizable on-line learning ($K > M$) exhibits a plateau in the " "generalization error (trapping in a symmetric subspace), followed by " "transition onto specialization and elimination of unnecessary nodes" -msgstr "" -"过可实现的在线学习($K > M$)的泛化误差会出现平台期(陷入对称子空间),随后" -"进入专化阶段并消除多余节点" +msgstr "过可实现的在线学习($K > M$)的泛化误差会出现平台期(陷入对称子空间),随后进入专化阶段并消除多余节点" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "" "Same qualitative signature: plateau → transition → specialization → " "elimination" -msgstr "" -"相同的定性特征:平台期 → 转换 → 专化 → 消除" +msgstr "相同的定性特征:平台期 → 转换 → 专化 → 消除" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Activation" msgstr "激活函数" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 #, python-brace-format msgid "$\\operatorname{erf}(x / \\sqrt{2})$" msgstr "$\\operatorname{erf}(x / \\sqrt{2})$" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 #, python-brace-format msgid "$\\tanh$ with gain $\\sqrt{2/\\pi}$ (slope-matched to erf at zero)" msgstr "增益为 $\\sqrt{2/\\pi}$ 的 $\\tanh$(在零点与 erf 的斜率匹配)" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Architecture" msgstr "架构" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "" -"Soft-committee machine: single hidden layer, output couplings fixed to $+1$," -" only input-to-hidden couplings are adaptive" -msgstr "" -"软委员会机:单个隐藏层,输出耦合固定为 $+1$,仅输入到隐藏层的耦合可自适应" +"Soft-committee machine: single hidden layer, output couplings fixed to " +"$+1$, only input-to-hidden couplings are adaptive" +msgstr "软委员会机:单个隐藏层,输出耦合固定为 $+1$,仅输入到隐藏层的耦合可自适应" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "" -"Same: single hidden layer, fixed $+1$ readout, only input-to-hidden weights " -"trainable" -msgstr "" -"相同:单个隐藏层,读出固定为 $+1$,仅输入到隐藏层的权重可训练" +"Same: single hidden layer, fixed $+1$ readout, only input-to-hidden " +"weights trainable" +msgstr "相同:单个隐藏层,读出固定为 $+1$,仅输入到隐藏层的权重可训练" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Data & Limit" msgstr "数据与极限" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Gaussian i.i.d. inputs, $N \\to \\infty$ thermodynamic limit" msgstr "高斯 i.i.d. 输入,热力学极限 $N \\to \\infty$" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Gaussian i.i.d. inputs, $N = 300$" msgstr "高斯 i.i.d. 输入,$N = 300$" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Optimizer" msgstr "优化器" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "On-line gradient descent, learning rate $\\eta$ scaled with $1/N$" msgstr "在线梯度下降,学习率 $\\eta$ 按 $1/N$ 缩放" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "SGD, batch size 1, learning rate $\\eta/N$" msgstr "SGD,批量大小 1,学习率 $\\eta/N$" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Error" msgstr "误差" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 #, python-brace-format msgid "$\\frac{1}{2} \\times$ quadratic deviation (Eq. 1)" msgstr "$\\frac{1}{2} \\times$ 二次偏差(式 1)" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Same: `mse_loss` with `lossScale = 0.5`" msgstr "相同:`mse_loss`,其中 `lossScale = 0.5`" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Initialization" msgstr "初始化" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 #, python-brace-format msgid "" "$R_{in} = 0$ for all $i, n$, $Q_{ik} = 0$ for all $i \\neq k$, $Q_{ii}$ " "drawn independently from uniform distribution in $[0, 0.5]$" msgstr "" -"对所有 $i, n$,$R_{in} = 0$;对所有 $i \\neq k$,$Q_{ik} = 0$;$Q_{ii}$" -" 独立地从 $[0, 0.5]$ 上的均匀分布抽取" +"对所有 $i, n$,$R_{in} = 0$;对所有 $i \\neq k$,$Q_{ik} = 0$;$Q_{ii}$ 独立地从 $[0, " +"0.5]$ 上的均匀分布抽取" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Same overlap geometry enforced via explicit weight payloads" msgstr "通过显式权重载荷强制采用相同的重叠几何结构" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Teacher overlaps" msgstr "教师网络重叠" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 #, python-brace-format msgid "$T_{nm} = n\\,\\delta_{nm}$, i.e. $\\operatorname{diag}(1, 2)$" msgstr "$T_{nm} = n\\,\\delta_{nm}$,即 $\\operatorname{diag}(1, 2)$" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Same, encoded in teacher's first-layer weights" msgstr "相同,编码在教师网络第一层的权重中" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Observables" msgstr "observables" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 #, python-brace-format msgid "" "Order parameters $Q_{ik}$, $R_{in}$, and generalization error " "$\\varepsilon_g$" -msgstr "" -"序参量 $Q_{ik}$、$R_{in}$ 和泛化误差 $\\varepsilon_g$" +msgstr "序参量 $Q_{ik}$、$R_{in}$ 和泛化误差 $\\varepsilon_g$" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 #, python-brace-format msgid "" "Test error ($\\frac{1}{2}$ × MSE), train error, weight L2 norm, and " "train-test gap" -msgstr "" -"测试误差($\\frac{1}{2}$ × MSE)、训练误差、权重 L2 范数和训练—测试误差差值" +msgstr "测试误差($\\frac{1}{2}$ × MSE)、训练误差、权重 L2 范数和训练—测试误差差值" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:104 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:106 msgid "" -"This graph tests a **mechanism-level pattern**, not a point-by-point match " -"to the paper's Fig. 1. The fixed seed and overlap-aware initialization make " -"the dynamics reproducible and easy to inspect, but do not establish " -"robustness across random seeds." +"This graph tests a **mechanism-level pattern**, not a point-by-point " +"match to the paper's Fig. 1. The fixed seed and overlap-aware " +"initialization make the dynamics reproducible and easy to inspect, but do" +" not establish robustness across random seeds." msgstr "" -"该图检验的是**机制层面的模式**,而非与论文图 1 逐点吻合。固定 seed 与重叠感知" -"初始化让动力学可重复且便于检查,但不能据此证明其对不同随机 seed 具有稳健性。" +"该图检验的是**机制层面的模式**,而非与论文图 1 逐点吻合。固定 seed " +"与重叠感知初始化让动力学可重复且便于检查,但不能据此证明其对不同随机 seed 具有稳健性。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:109 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:111 msgid "Experiment Configuration" msgstr "实验配置" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Template setting" msgstr "模板设置" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Dataset" msgstr "数据集" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 #, python-brace-format msgid "" -"Fixed:   training samples drawn from $\\mathcal{N}(0, I_N)$, sampled " -"with replacement each step. Test evaluation uses a separate fixed pool of " -"30,000 samples. Seed 23." +"Fixed:   training samples drawn from $\\mathcal{N}(0, I_N)$, sampled" +" with replacement each step. Test evaluation uses a separate fixed pool " +"of 30,000 samples. Seed 23." msgstr "" -"固定:  训练样本从 $\\mathcal{N}(0, I_N)$ 抽取,每一步均有放回采样。测试" -"评估使用独立的固定样本池,共 30,000 个样本。seed 为 23。" +"固定:  训练样本从 $\\mathcal{N}(0, I_N)$ 抽取,每一步均有放回采样。测试评估使用独立的固定样本池,共 " +"30,000 个样本。seed 为 23。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Input dimension" msgstr "输入维度" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "$N = 300$" msgstr "$N = 300$" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Teacher network" msgstr "教师网络" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 #, python-brace-format msgid "" -"Soft-committee machine, $M = 2$ hidden nodes, tanh activation, fixed $+1$ " -"readout, weights frozen (`freeze: true`). Teacher overlaps: $T = " +"Soft-committee machine, $M = 2$ hidden nodes, tanh activation, fixed $+1$" +" readout, weights frozen (`freeze: true`). Teacher overlaps: $T = " "\\operatorname{diag}(1, 2)$." msgstr "" -"软委员会机,$M = 2$ 个隐藏节点,tanh 激活,读出固定为 $+1$,权重冻结" -"(`freeze: true`)。教师网络重叠:$T = \\operatorname{diag}(1, 2)$。" +"软委员会机,$M = 2$ 个隐藏节点,tanh 激活,读出固定为 $+1$,权重冻结(`freeze: true`)。教师网络重叠:$T = " +"\\operatorname{diag}(1, 2)$。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Student network" msgstr "学生网络" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 #, python-brace-format msgid "" -"Soft-committee machine, $K = 3$ hidden nodes, tanh activation, fixed $+1$ " -"readout. Only the input-to-hidden couplings are adaptive. Initialized with " -"$R_{in} = 0$, off-diagonal $Q_{ik} = 0$." +"Soft-committee machine, $K = 3$ hidden nodes, tanh activation, fixed $+1$" +" readout. Only the input-to-hidden couplings are adaptive. Initialized " +"with $R_{in} = 0$, off-diagonal $Q_{ik} = 0$." msgstr "" -"软委员会机,$K = 3$ 个隐藏节点,tanh 激活,读出固定为 $+1$。仅输入到隐藏层的" -"耦合可自适应。初始化满足 $R_{in} = 0$、非对角 $Q_{ik} = 0$。" +"软委员会机,$K = 3$ 个隐藏节点,tanh 激活,读出固定为 $+1$。仅输入到隐藏层的耦合可自适应。初始化满足 $R_{in} = " +"0$、非对角 $Q_{ik} = 0$。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Activation scaling" msgstr "激活缩放" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 #, python-brace-format msgid "" "`tanh(gain * x)` with `gain = sqrt(2/π)`, slope-matched to " @@ -403,355 +388,333 @@ msgstr "" "`tanh(gain * x)`,其中 `gain = sqrt(2/π)`,在零点处与 " "$\\operatorname{erf}(x/\\sqrt{2})$ 斜率匹配。该增益已折入第一层权重载荷。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Objective" msgstr "目标函数" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 #, python-brace-format msgid "$\\frac{1}{2} \\times$ MSE (`mse_loss.lossScale = 0.5`)" msgstr "$\\frac{1}{2} \\times$ MSE(`mse_loss.lossScale = 0.5`)" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "" "SGD, learning rate $\\eta/N= (2/\\pi)/300 \\approx 0.0021$, momentum 0, " "weight decay 0" -msgstr "" -"SGD,学习率 $\\eta/N = (2/\\pi)/300 \\approx 0.0021$,动量 0,权重衰减 0" +msgstr "SGD,学习率 $\\eta/N = (2/\\pi)/300 \\approx 0.0021$,动量 0,权重衰减 0" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Batch size" msgstr "批量大小" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "1" msgstr "1" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Device" msgstr "设备" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "CPU" msgstr "CPU" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "Training" msgstr "训练" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 msgid "60000 steps, log every 100 steps, $\\alpha = P/N = 200$" msgstr "60,000 步,每 100 步记录一次,$\\alpha = P/N = 200$" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:41 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:43 #, python-brace-format msgid "" -"Test error ($\\frac{1}{2}$ × MSE), train error, train-test gap, weight L2 " -"norm" -msgstr "" -"测试误差($\\frac{1}{2}$ × MSE)、训练误差、训练—测试误差差值、权重 L2 范数" +"Test error ($\\frac{1}{2}$ × MSE), train error, train-test gap, weight L2" +" norm" +msgstr "测试误差($\\frac{1}{2}$ × MSE)、训练误差、训练—测试误差差值、权重 L2 范数" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:125 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:127 msgid "" "A separate large test dataset (wired via a second `teacher_dataset` as " "`test_dataset`) keeps the test-error curve smooth enough to resolve the " "trapping regime without single-sample noise." msgstr "" -"通过第二个 `teacher_dataset` 作为 `test_dataset` 接入独立的大型测试数据集,可使" -"测试误差曲线足够平滑,从而分辨陷入对称子空间的阶段,而不受单样本噪声干扰。" +"通过第二个 `teacher_dataset` 作为 `test_dataset` " +"接入独立的大型测试数据集,可使测试误差曲线足够平滑,从而分辨陷入对称子空间的阶段,而不受单样本噪声干扰。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:129 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:131 msgid "" "The teacher weights are frozen (`trainable: false`, `freeze: true`, " "`requiresGrad: false`), as are both the teacher and student readout " -"couplings (fixed at $+1$). Only the student's input-to-hidden weight matrix " -"is adaptive -- matching the paper's soft-committee machine in which \"all the" -" hidden units are connected to the output unit with positive couplings of " -"unit strength, and only the input-to-hidden couplings are adaptive.\"" +"couplings (fixed at $+1$). Only the student's input-to-hidden weight " +"matrix is adaptive -- matching the paper's soft-committee machine in " +"which \"all the hidden units are connected to the output unit with " +"positive couplings of unit strength, and only the input-to-hidden " +"couplings are adaptive.\"" msgstr "" -"教师权重冻结(`trainable: false`、`freeze: true`、`requiresGrad: false`),教师与" -"学生的读出耦合也均冻结(固定为 $+1$)。只有学生从输入到隐藏层的权重矩阵可" -"自适应——这与论文中的软委员会机一致:“所有隐藏单元均以单位强度的正耦合连接至" -"输出单元,且仅输入到隐藏层的耦合可自适应。”" +"教师权重冻结(`trainable: false`、`freeze: true`、`requiresGrad: " +"false`),教师与学生的读出耦合也均冻结(固定为 " +"$+1$)。只有学生从输入到隐藏层的权重矩阵可自适应——这与论文中的软委员会机一致:“所有隐藏单元均以单位强度的正耦合连接至输出单元,且仅输入到隐藏层的耦合可自适应。”" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:136 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:138 msgid "Run in ComfyResearch" msgstr "在 ComfyResearch 中运行" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:138 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:140 msgid "" "Open **Templates** and load **`Saad & Solla Over-Realizable (CPU)`** (or " "import the workspace JSON)." -msgstr "" -"打开 **Templates**,加载 **`Saad & Solla Over-Realizable (CPU)`**(或导入工作区" -" JSON)。" +msgstr "打开 **Templates**,加载 **`Saad & Solla Over-Realizable (CPU)`**(或导入工作区 JSON)。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:140 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:142 msgid "" "Confirm Trainer settings: CPU, batch size 1, fixed sampling mode on the " "teacher dataset." -msgstr "" -"确认 Trainer 设置:CPU、批量大小 1,以及教师数据集的固定采样模式。" +msgstr "确认 Trainer 设置:CPU、批量大小 1,以及教师数据集的固定采样模式。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:142 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:144 msgid "" -"Verify that both the Teacher and Student `combined_model` nodes contain the " -"atomic layers: `linear_layer` (input-to-hidden) → `activation_layer` (tanh) " -"→ `linear_layer` (fixed $+1$ readout)." +"Verify that both the Teacher and Student `combined_model` nodes contain " +"the atomic layers: `linear_layer` (input-to-hidden) → `activation_layer` " +"(tanh) → `linear_layer` (fixed $+1$ readout)." msgstr "" -"确认 Teacher 和 Student 的 `combined_model` 节点均包含以下原子层:" -"`linear_layer`(输入到隐藏层)→ `activation_layer`(tanh)→ `linear_layer`" -"(固定为 $+1$ 的读出层)。" +"确认 Teacher 和 Student 的 `combined_model` " +"节点均包含以下原子层:`linear_layer`(输入到隐藏层)→ `activation_layer`(tanh)→ " +"`linear_layer`(固定为 $+1$ 的读出层)。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:145 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:147 msgid "" -"Confirm that only the student's first `linear_layer` is marked trainable; " -"teacher layers and both readout layers should show `freeze: true` / " +"Confirm that only the student's first `linear_layer` is marked trainable;" +" teacher layers and both readout layers should show `freeze: true` / " "`trainable: false`." msgstr "" -"确认只有学生网络的第一个 `linear_layer` 标记为可训练;教师网络各层及两个读出层" -"均应显示 `freeze: true` / `trainable: false`。" +"确认只有学生网络的第一个 `linear_layer` 标记为可训练;教师网络各层及两个读出层均应显示 `freeze: true` / " +"`trainable: false`。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:148 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:150 msgid "Click **Train** on the `Trainer` node." msgstr "点击 `Trainer` 节点上的 **Train**。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:149 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:151 msgid "" "Inspect the test error curve in **Training Viz**. The plateau should be " "visible as a long, nearly flat segment." -msgstr "" -"在 **Training Viz** 中查看测试误差曲线。平台期应呈现为一段长而近乎平坦的曲线。" +msgstr "在 **Training Viz** 中查看测试误差曲线。平台期应呈现为一段长而近乎平坦的曲线。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:151 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:153 msgid "" "Inspect the weight L2 norm in **Observable viz** to see the overall " "magnitude of the student's adaptive couplings." -msgstr "" -"在 **Observable viz** 中查看权重 L2 范数,了解学生网络自适应耦合的整体幅度。" +msgstr "在 **Observable viz** 中查看权重 L2 范数,了解学生网络自适应耦合的整体幅度。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:153 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:155 msgid "Optionally, inspect the train-test gap." msgstr "也可查看训练—测试误差差值。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:155 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:157 msgid "Results" msgstr "结果" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:157 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:159 msgid "" -"The test error curve exhibits the qualitative signature predicted by Saad & " -"Solla (1995) for the over-realizable case ($K = 3 > M = 2$):" -msgstr "" -"测试误差曲线呈现 Saad & Solla(1995)针对过可实现情形($K = 3 > M = 2$)" -"预测的定性特征:" +"The test error curve exhibits the qualitative signature predicted by Saad" +" & Solla (1995) for the over-realizable case ($K = 3 > M = 2$):" +msgstr "测试误差曲线呈现 Saad & Solla(1995)针对过可实现情形($K = 3 > M = 2$)预测的定性特征:" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:160 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:162 msgid "" "**Initial decay** -- the generalization error drops rapidly at small " "$\\alpha$." -msgstr "" -"**初始衰减**——在 $\\alpha$ 较小时,泛化误差迅速下降。" +msgstr "**初始衰减**——在 $\\alpha$ 较小时,泛化误差迅速下降。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:162 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:164 #, python-brace-format msgid "" -"**Trapping in the symmetric subspace (plateau)** -- the error enters a long, " -"nearly flat plateau where the student is trapped near a symmetric fixed " -"point. All three student hidden nodes have approximately equal norms " -"($Q_{11} \\approx Q_{22} \\approx Q_{33}$), with no differentiation among " -"them. Because no student node has committed to a particular teacher node, " -"the gradient signal is weak and the generalization error decreases only very" -" slowly. This corresponds to the \"small $\\alpha$ behavior dominated by an " -"undifferentiated symmetric solution\" described in the paper." -msgstr "" -"**陷入对称子空间(平台期)**——误差进入长而近乎平坦的平台期,学生停留在对称" -"不动点附近。三个学生隐藏节点的范数近似相等($Q_{11} \\approx Q_{22} \\approx " -"Q_{33}$),尚未分化。由于没有学生节点专门对应某个教师节点,梯度信号很弱,泛化" -"误差只能极缓慢地下降。这对应论文所述“由未分化对称解主导的小 $\\alpha$ 行为”。" - -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:170 -msgid "" -"**Transition onto specialization** -- the symmetric solution gives way to " -"differentiation: student hidden nodes begin to specialize, each aligning to " -"a different teacher node. The generalization error resumes a sustained " -"decrease." -msgstr "" -"**进入专化阶段**——对称解让位于分化:学生隐藏节点开始专化,分别与不同的教师" -"节点对齐。泛化误差重新持续下降。" - -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:174 +"**Trapping in the symmetric subspace (plateau)** -- the error enters a " +"long, nearly flat plateau where the student is trapped near a symmetric " +"fixed point. All three student hidden nodes have approximately equal " +"norms ($Q_{11} \\approx Q_{22} \\approx Q_{33}$), with no differentiation" +" among them. Because no student node has committed to a particular " +"teacher node, the gradient signal is weak and the generalization error " +"decreases only very slowly. This corresponds to the \"small $\\alpha$ " +"behavior dominated by an undifferentiated symmetric solution\" described " +"in the paper." +msgstr "" +"**陷入对称子空间(平台期)**——误差进入长而近乎平坦的平台期,学生停留在对称不动点附近。三个学生隐藏节点的范数近似相等($Q_{11} " +"\\approx Q_{22} \\approx " +"Q_{33}$),尚未分化。由于没有学生节点专门对应某个教师节点,梯度信号很弱,泛化误差只能极缓慢地下降。这对应论文所述“由未分化对称解主导的小 " +"$\\alpha$ 行为”。" + +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:172 +msgid "" +"**Transition onto specialization** -- the symmetric solution gives way " +"to differentiation: student hidden nodes begin to specialize, each " +"aligning to a different teacher node. The generalization error resumes a " +"sustained decrease." +msgstr "**进入专化阶段**——对称解让位于分化:学生隐藏节点开始专化,分别与不同的教师节点对齐。泛化误差重新持续下降。" + +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:176 #, python-brace-format msgid "" -"**Elimination of the unnecessary node** -- the redundant third student node " -"decays toward zero ($Q_{33} \\to 0$), while the two surviving nodes imitate " -"the two teacher nodes. Asymptotically, $Q_{11} = R_{11} = T_{11}$ and " -"$Q_{22} = R_{22} = T_{22}$, with vanishing correlations between the " +"**Elimination of the unnecessary node** -- the redundant third student " +"node decays toward zero ($Q_{33} \\to 0$), while the two surviving nodes " +"imitate the two teacher nodes. Asymptotically, $Q_{11} = R_{11} = T_{11}$" +" and $Q_{22} = R_{22} = T_{22}$, with vanishing correlations between the " "surviving student vectors ($Q_{12} \\to 0$)." msgstr "" -"**消除多余节点**——冗余的第三个学生节点衰减至零($Q_{33} \\to 0$),余下两个" -"节点分别拟合两个教师节点。渐近地,$Q_{11} = R_{11} = T_{11}$ 且 $Q_{22} = " -"R_{22} = T_{22}$,存活的学生向量之间的相关性消失($Q_{12} \\to 0$)。" +"**消除多余节点**——冗余的第三个学生节点衰减至零($Q_{33} \\to 0$),余下两个节点分别拟合两个教师节点。渐近地,$Q_{11} " +"= R_{11} = T_{11}$ 且 $Q_{22} = R_{22} = T_{22}$,存活的学生向量之间的相关性消失($Q_{12} " +"\\to 0$)。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:179 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:181 msgid "" -"**Perfect generalization** -- the generalization error approaches zero as the" -" student perfectly matches the teacher after eliminating the unnecessary " -"node. This is the defining signature of the over-realizable regime: the " -"student has sufficient resources ($K > M$) to implement the task exactly." +"**Perfect generalization** -- the generalization error approaches zero " +"as the student perfectly matches the teacher after eliminating the " +"unnecessary node. This is the defining signature of the over-realizable " +"regime: the student has sufficient resources ($K > M$) to implement the " +"task exactly." msgstr "" -"**完美泛化**——消除多余节点后,学生与教师完全匹配,泛化误差趋近于零。这是过可" -"实现区域的标志性特征:学生拥有足够资源($K > M$),能够精确实现该任务。" +"**完美泛化**——消除多余节点后,学生与教师完全匹配,泛化误差趋近于零。这是过可实现区域的标志性特征:学生拥有足够资源($K > " +"M$),能够精确实现该任务。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:184 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:186 msgid "" "Comfy Research graph showing test error over the normalized number of " -"examples, with a visible plateau corresponding to trapping in the symmetric " -"subspace, followed by a transition onto specialization and convergence " -"toward perfect generalization." -msgstr "" -"Comfy Research 图展示测试误差随归一化样本数的变化:可见的平台期对应陷入对称" -"子空间,随后进入专化阶段,并收敛至完美泛化。" +"examples, with a visible plateau corresponding to trapping in the " +"symmetric subspace, followed by a transition onto specialization and " +"convergence toward perfect generalization." +msgstr "Comfy Research 图展示测试误差随归一化样本数的变化:可见的平台期对应陷入对称子空间,随后进入专化阶段,并收敛至完美泛化。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:189 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:191 msgid "Interpretation" msgstr "解读" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:191 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:193 msgid "" -"The observed curve is qualitatively consistent with the dynamical picture of" -" Saad & Solla (1995):" -msgstr "" -"观测到的曲线在定性上与 Saad & Solla(1995)描述的动力学图景一致:" +"The observed curve is qualitatively consistent with the dynamical picture" +" of Saad & Solla (1995):" +msgstr "观测到的曲线在定性上与 Saad & Solla(1995)描述的动力学图景一致:" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:194 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:196 msgid "" -"The **trapping in the symmetric subspace** (visible as a **plateau** in the " -"error curve) reflects the dynamics near a symmetric fixed point of the " -"order-parameter ODEs (Eq. 8), where all student weight vectors have " +"The **trapping in the symmetric subspace** (visible as a **plateau** in " +"the error curve) reflects the dynamics near a symmetric fixed point of " +"the order-parameter ODEs (Eq. 8), where all student weight vectors have " "approximately equal overlaps with each teacher node. In this regime the " -"gradient signal is weak because no student node has committed to a specific " -"teacher direction." +"gradient signal is weak because no student node has committed to a " +"specific teacher direction." msgstr "" -"**陷入对称子空间**(在误差曲线上呈现为**平台期**)反映序参量 ODE(式 8)在" -"对称不动点附近的动力学;此时,所有学生权重向量与每个教师节点的重叠近似相等。" -"在该阶段,没有学生节点专门对应某个教师方向,因此梯度信号很弱。" +"**陷入对称子空间**(在误差曲线上呈现为**平台期**)反映序参量 ODE(式 " +"8)在对称不动点附近的动力学;此时,所有学生权重向量与每个教师节点的重叠近似相等。在该阶段,没有学生节点专门对应某个教师方向,因此梯度信号很弱。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:200 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:202 msgid "" -"The **transition onto specialization** corresponds to the destabilization of" -" the symmetric fixed point: small fluctuations (amplified by the finite " -"input dimension and the stochasticity of on-line sampling) eventually push " -"one student node to align more strongly with a particular teacher node, " -"triggering a positive-feedback cascade of differentiation." -msgstr "" -"**进入专化阶段**对应对称不动点失稳:微小涨落会被有限输入维度和在线采样的随机性" -"放大,最终使某个学生节点更强地与特定教师节点对齐,并触发分化的正反馈级联。" +"The **transition onto specialization** corresponds to the destabilization" +" of the symmetric fixed point: small fluctuations (amplified by the " +"finite input dimension and the stochasticity of on-line sampling) " +"eventually push one student node to align more strongly with a particular" +" teacher node, triggering a positive-feedback cascade of differentiation." +msgstr "**进入专化阶段**对应对称不动点失稳:微小涨落会被有限输入维度和在线采样的随机性放大,最终使某个学生节点更强地与特定教师节点对齐,并触发分化的正反馈级联。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:205 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:207 msgid "" "The **convergence to perfect generalization** is expected in the over-" -"realizable case: the student has more hidden nodes than the teacher ($K = 3 " -"> M = 2$) and, after eliminating the unnecessary node, can represent the " -"teacher exactly." -msgstr "" -"在过可实现情形下,**收敛至完美泛化**符合预期:学生的隐藏节点多于教师" -"($K = 3 > M = 2$),消除多余节点后便可精确表示教师。" +"realizable case: the student has more hidden nodes than the teacher ($K =" +" 3 > M = 2$) and, after eliminating the unnecessary node, can represent " +"the teacher exactly." +msgstr "在过可实现情形下,**收敛至完美泛化**符合预期:学生的隐藏节点多于教师($K = 3 > M = 2$),消除多余节点后便可精确表示教师。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:210 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:212 msgid "" -"Key design choices that were necessary to reproduce the paper's qualitative " -"behavior:" -msgstr "" -"复现论文定性行为所必需的关键设计选择如下:" +"Key design choices that were necessary to reproduce the paper's " +"qualitative behavior:" +msgstr "复现论文定性行为所必需的关键设计选择如下:" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:213 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:215 #, python-brace-format msgid "" -"**tanh activation** (with erf-matched gain): ReLU lacks saturation and does " -"not support the symmetric fixed point required for trapping. The original " -"paper uses erf; tanh with slope-matching gain $\\sqrt{2/\\pi}$ at zero is " -"the closest available option in Comfy Research." +"**tanh activation** (with erf-matched gain): ReLU lacks saturation and " +"does not support the symmetric fixed point required for trapping. The " +"original paper uses erf; tanh with slope-matching gain $\\sqrt{2/\\pi}$ " +"at zero is the closest available option in Comfy Research." msgstr "" -"**tanh 激活**(erf 匹配增益):ReLU 没有饱和区,无法支持陷入对称子空间所需的" -"对称不动点。原论文使用 erf;在 Comfy Research 中,零点处以 $\\sqrt{2/\\pi}$" -" 进行斜率匹配的 tanh 是最接近的可用选项。" +"**tanh 激活**(erf 匹配增益):ReLU 没有饱和区,无法支持陷入对称子空间所需的对称不动点。原论文使用 erf;在 Comfy " +"Research 中,零点处以 $\\sqrt{2/\\pi}$ 进行斜率匹配的 tanh 是最接近的可用选项。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:217 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:219 msgid "" "**Fixed $+1$ readout**: the paper's soft-committee machine connects all " "hidden units to the output with \"positive couplings of unit strength.\" " "Allowing the readout to train would give the student additional adaptive " "degrees of freedom beyond what the paper's theory describes." msgstr "" -"**固定 $+1$ 读出层**:论文中的软委员会机将所有隐藏单元以“单位强度的正耦合”" -"连接至输出。允许训练读出层会给学生额外的自适应自由度,超出论文理论描述的范围。" +"**固定 $+1$ " +"读出层**:论文中的软委员会机将所有隐藏单元以“单位强度的正耦合”连接至输出。允许训练读出层会给学生额外的自适应自由度,超出论文理论描述的范围。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:221 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:223 #, python-brace-format msgid "" -"**Overlap-aware initialization**: the paper's initial conditions ($R_{in} = " -"0$, off-diagonal $Q_{ik} = 0$) are essential for the dynamics to pass " +"**Overlap-aware initialization**: the paper's initial conditions ($R_{in}" +" = 0$, off-diagonal $Q_{ik} = 0$) are essential for the dynamics to pass " "through the symmetric subspace. Random initialization would mix these " "overlaps and obscure the trapping and transition." msgstr "" -"**重叠感知初始化**:论文的初始条件($R_{in} = 0$、非对角 $Q_{ik} = 0$)是" -"动力学经过对称子空间的关键。随机初始化会混合这些重叠关系,掩盖陷入和转换过程。" +"**重叠感知初始化**:论文的初始条件($R_{in} = 0$、非对角 $Q_{ik} = " +"0$)是动力学经过对称子空间的关键。随机初始化会混合这些重叠关系,掩盖陷入和转换过程。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:225 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:227 #, python-brace-format msgid "" -"**Sufficiently large input dimension $N$**: smaller $N$ introduces strong " -"$O(1/\\sqrt{N})$ fluctuations that can prematurely destabilize the symmetric" -" solution, shortening or eliminating the trapping regime." +"**Sufficiently large input dimension $N$**: smaller $N$ introduces strong" +" $O(1/\\sqrt{N})$ fluctuations that can prematurely destabilize the " +"symmetric solution, shortening or eliminating the trapping regime." msgstr "" -"**足够大的输入维度 $N$**:较小的 $N$ 会引入较强的 $O(1/\\sqrt{N})$ 涨落," -"可能使对称解过早失稳,从而缩短甚至消除陷入阶段。" +"**足够大的输入维度 $N$**:较小的 $N$ 会引入较强的 $O(1/\\sqrt{N})$ " +"涨落,可能使对称解过早失稳,从而缩短甚至消除陷入阶段。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:228 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:230 msgid "" -"**Appropriate learning rate $\\eta$**: too small a learning rate produces " -"smooth descent with no discernible trapping; the discrete-time dynamics need" -" a sufficiently large step for the student to remain trapped near the " -"symmetric fixed point." -msgstr "" -"**适当的学习率 $\\eta$**:学习率过小会产生平滑下降,难以辨认陷入阶段;离散时间" -"动力学需要足够大的步长,才能让学生停留在对称不动点附近。" +"**Appropriate learning rate $\\eta$**: too small a learning rate produces" +" smooth descent with no discernible trapping; the discrete-time dynamics " +"need a sufficiently large step for the student to remain trapped near the" +" symmetric fixed point." +msgstr "**适当的学习率 $\\eta$**:学习率过小会产生平滑下降,难以辨认陷入阶段;离散时间动力学需要足够大的步长,才能让学生停留在对称不动点附近。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:233 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:235 msgid "Limitations" msgstr "局限性" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:235 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:237 #, python-brace-format msgid "" -"The tanh activation is an approximation to the paper's erf; while the slope " -"at zero is matched via the gain factor $\\sqrt{2/\\pi}$, the saturation " -"behavior differs for large inputs, which affects the exact duration of the " -"trapping regime and the location of the transition." +"The tanh activation is an approximation to the paper's erf; while the " +"slope at zero is matched via the gain factor $\\sqrt{2/\\pi}$, the " +"saturation behavior differs for large inputs, which affects the exact " +"duration of the trapping regime and the location of the transition." msgstr "" -"tanh 激活是论文中 erf 的近似;虽然增益因子 $\\sqrt{2/\\pi}$ 让两者在零点处的" -"斜率匹配,但其在大输入下的饱和行为不同,会影响陷入阶段的确切持续时间和转换位置。" +"tanh 激活是论文中 erf 的近似;虽然增益因子 $\\sqrt{2/\\pi}$ " +"让两者在零点处的斜率匹配,但其在大输入下的饱和行为不同,会影响陷入阶段的确切持续时间和转换位置。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:239 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:241 #, python-brace-format msgid "" "The finite input dimension introduces $O(1/\\sqrt{N})$ fluctuations not " -"present in the thermodynamic limit $N \\to \\infty$. These fluctuations both" -" enable the escape from the symmetric subspace (which would be perfectly " -"stable in the limit) and add noise to the error curve." +"present in the thermodynamic limit $N \\to \\infty$. These fluctuations " +"both enable the escape from the symmetric subspace (which would be " +"perfectly stable in the limit) and add noise to the error curve." msgstr "" -"有限输入维度会引入热力学极限 $N \\to \\infty$ 中不存在的 $O(1/\\sqrt{N})$" -" 涨落。这些涨落既使系统得以逃离对称子空间(该子空间在极限下完全稳定),也会给" -"误差曲线带来噪声。" +"有限输入维度会引入热力学极限 $N \\to \\infty$ 中不存在的 $O(1/\\sqrt{N})$ " +"涨落。这些涨落既使系统得以逃离对称子空间(该子空间在极限下完全稳定),也会给误差曲线带来噪声。" -#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:243 +#: ../../en/examples/reproductions/saad_solla_plateau_reproduction.md:245 #, python-brace-format msgid "" "One fixed seed and one set of initial $Q_{ii}$ values do not measure " -"initialization-to-initialization variation. Different initial conditions can" -" produce trapping regimes of different lengths, and some seeds may escape " -"the symmetric subspace earlier or later." +"initialization-to-initialization variation. Different initial conditions " +"can produce trapping regimes of different lengths, and some seeds may " +"escape the symmetric subspace earlier or later." msgstr "" -"单个固定 seed 和一组初始 $Q_{ii}$ 值无法衡量初始化之间的变化。不同初始条件" -"可能产生持续时间不同的陷入阶段,有些 seed 会使系统更早或更晚逃离对称子空间。" +"单个固定 seed 和一组初始 $Q_{ii}$ 值无法衡量初始化之间的变化。不同初始条件可能产生持续时间不同的陷入阶段,有些 seed " +"会使系统更早或更晚逃离对称子空间。" + diff --git a/docs/locales/zh_CN/LC_MESSAGES/extend/add-observable.po b/docs/locales/zh_CN/LC_MESSAGES/extend/add-observable.po index f302984..847d42e 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/extend/add-observable.po +++ b/docs/locales/zh_CN/LC_MESSAGES/extend/add-observable.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-24 14:05+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: zh_CN\n" @@ -29,8 +29,8 @@ msgstr "添加标量 Observable" #: ../../en/extend/add-observable.md:13 msgid "" -"Define one measurement, register its runtime recorder, regenerate the shared" -" contract, and verify the complete backend-to-frontend path." +"Define one measurement, register its runtime recorder, regenerate the " +"shared contract, and verify the complete backend-to-frontend path." msgstr "定义一项测量,注册其运行时 recorder,重新生成共享契约,并验证完整的后端到前端路径。" #: ../../en/extend/add-observable.md:17 @@ -50,8 +50,9 @@ msgstr "" #: ../../en/extend/add-observable.md:24 msgid "" "This tutorial uses an Observable because its declaration and runtime " -"measurement can live in one file. For every supported Node channel and its " -"registration contract, see [Node contracts](../reference/node-contracts.md)." +"measurement can live in one file. For every supported Node channel and " +"its registration contract, see [Node contracts](../reference/node-" +"contracts.md)." msgstr "" "本教程使用 Observable,因为其声明和运行时测量可位于同一个文件中。所有受支持 Node 通道及其注册契约请参阅[Node " "契约](../reference/node-contracts.md)。" @@ -77,8 +78,8 @@ msgid "" "`variant=\"user\"` and `SpawnSpec(kind=\"user_scalar\")` use the generic " "scalar visualization path, so no custom React component is needed." msgstr "" -"`variant=\"user\"` 和 `SpawnSpec(kind=\"user_scalar\")` 使用通用标量可视化路径,因此不需要自定义 " -"React 组件。" +"`variant=\"user\"` 和 `SpawnSpec(kind=\"user_scalar\")` " +"使用通用标量可视化路径,因此不需要自定义 React 组件。" #: ../../en/extend/add-observable.md:87 msgid "The recorder appends exactly one value for each call." @@ -86,14 +87,14 @@ msgstr "recorder 每次调用恰好追加一个值。" #: ../../en/extend/add-observable.md:88 msgid "" -"Heavy imports stay inside the provider function so definition discovery and " -"generation remain lightweight." +"Heavy imports stay inside the provider function so definition discovery " +"and generation remain lightweight." msgstr "较重的导入保留在 provider 函数内部,以使定义发现和生成保持轻量。" #: ../../en/extend/add-observable.md:90 msgid "" -"`validate_defs()` requires a registered Observable to have one recorder and " -"prevents a recorder from existing without its definition." +"`validate_defs()` requires a registered Observable to have one recorder " +"and prevents a recorder from existing without its definition." msgstr "`validate_defs()` 要求已注册 Observable 有一个 recorder,并阻止 recorder 在没有其定义时存在。" #: ../../en/extend/add-observable.md:93 @@ -106,9 +107,9 @@ msgstr "在 `fields` 中声明用户可编辑参数,例如:" #: ../../en/extend/add-observable.md:105 msgid "" -"The value is serialized under the node's `data` object and is available to " -"the recorder through `on.data`. Use the narrowest field type and an explicit" -" minimum when the UI can reject invalid values." +"The value is serialized under the node's `data` object and is available " +"to the recorder through `on.data`. Use the narrowest field type and an " +"explicit minimum when the UI can reject invalid values." msgstr "" "该值序列化在 Node 的 `data` 对象下,并通过 `on.data` 提供给 recorder。当 UI " "可以拒绝无效值时,请使用最窄的字段类型和显式最小值。" @@ -139,8 +140,8 @@ msgstr "运行定义和生成契约:" #: ../../en/extend/add-observable.md:131 msgid "" "Add a focused provider test that constructs the smallest recorder state, " -"calls `record(...)`, and asserts the exact history value. Then verify the " -"frontend:" +"calls `record(...)`, and asserts the exact history value. Then verify the" +" frontend:" msgstr "添加聚焦的 provider 测试:构造最小 recorder 状态,调用 `record(...)`,并断言精确历史值。然后验证前端:" #: ../../en/extend/add-observable.md:138 @@ -167,78 +168,56 @@ msgstr "训练按配置的记录频率追加数值。" msgid "Saving and reopening the graph preserves its type and fields." msgstr "保存并重新打开图会保留其类型和字段。" -#: ../../en/extend/add-observable.md:146 -msgid "Product screenshot pending · IMG-11" -msgstr "产品截图待补 · IMG-11" - -#: ../../en/extend/add-observable.md:149 -msgid "" -"**Purpose:** Verify the extension across generation, discovery, graph " -"wiring, and runtime output rather than showing code alone." -msgstr "**目的:** 验证扩展的生成、发现、图连线和运行时输出,而不只展示代码。" - -#: ../../en/extend/add-observable.md -msgid "Capture specification" -msgstr "截图说明" - -#: ../../en/extend/add-observable.md:156 -msgid "" -"**Capture:** The tutorial Observable visible in node search and either " -"connected to a Trainer or producing its paired scalar visualization." -msgstr "**截图:** 在 Node 搜索中展示教程 Observable,并展示其已连接到 Trainer 或生成配套标量可视化。" - -#: ../../en/extend/add-observable.md:159 -msgid "**Final file:** `docs/en/_images/app/custom-observable-registered.png`" -msgstr "**最终文件:** `docs/en/_images/app/custom-observable-registered.png`" - -#: ../../en/extend/add-observable.md:163 +#: ../../en/extend/add-observable.md:147 msgid "When a custom frontend is justified" msgstr "何时需要自定义前端" -#: ../../en/extend/add-observable.md:165 +#: ../../en/extend/add-observable.md:149 msgid "" "`FrontendSpec()` selects the generic component. Keep it when fields, " "sockets, and a standard scalar or series visualization are sufficient." msgstr "`FrontendSpec()` 选择通用组件。字段、sockets 和标准标量或序列可视化已足够时,请保留它。" -#: ../../en/extend/add-observable.md:168 +#: ../../en/extend/add-observable.md:152 msgid "" -"A custom React component is warranted only when the Node needs interaction " -"or visualization that the generated schema cannot express. In that case, add" -" a stable `component_key`, implement and register the component in the " -"frontend, and add a node-registry invariant test. Do not add a custom " -"component only to change spacing, labels, or basic numeric controls." +"A custom React component is warranted only when the Node needs " +"interaction or visualization that the generated schema cannot express. In" +" that case, add a stable `component_key`, implement and register the " +"component in the frontend, and add a node-registry invariant test. Do not" +" add a custom component only to change spacing, labels, or basic numeric " +"controls." msgstr "" "仅当 Node 需要生成 schema 无法表达的交互或可视化时,才需要自定义 React 组件。此时请添加稳定的 " "`component_key`,在前端实现并注册该组件,并添加 node-registry " "不变量测试。不要仅为改变间距、标签或基本数值控件而添加自定义组件。" -#: ../../en/extend/add-observable.md:174 +#: ../../en/extend/add-observable.md:158 msgid "Contribution checklist" msgstr "贡献检查清单" -#: ../../en/extend/add-observable.md:176 +#: ../../en/extend/add-observable.md:160 msgid "Start from current `main` and use a focused topic branch." msgstr "从当前 `main` 开始,并使用聚焦的主题分支。" -#: ../../en/extend/add-observable.md:177 +#: ../../en/extend/add-observable.md:161 msgid "Keep the new Node type and saved-graph compatibility stable." msgstr "保持新 Node 类型和已保存图兼容性稳定。" -#: ../../en/extend/add-observable.md:178 +#: ../../en/extend/add-observable.md:162 msgid "Regenerate artifacts; never hand-edit them." msgstr "重新生成产物;绝不要手动编辑它们。" -#: ../../en/extend/add-observable.md:179 +#: ../../en/extend/add-observable.md:163 msgid "Test runtime behavior and frontend registration." msgstr "测试运行时行为和前端注册。" -#: ../../en/extend/add-observable.md:180 +#: ../../en/extend/add-observable.md:164 msgid "" "Include a Small template when the Node is best understood in a complete " "graph." msgstr "当完整图最能说明该 Node 时,请附上 Small 模板。" -#: ../../en/extend/add-observable.md:181 +#: ../../en/extend/add-observable.md:165 msgid "Explain the scientific purpose and limitations of the measurement." msgstr "说明测量的科学目的和局限性。" + diff --git a/docs/locales/zh_CN/LC_MESSAGES/get-started/first-graph.po b/docs/locales/zh_CN/LC_MESSAGES/get-started/first-graph.po index 8f21bc9..89a2108 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/get-started/first-graph.po +++ b/docs/locales/zh_CN/LC_MESSAGES/get-started/first-graph.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-24 19:32+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: zh_CN\n" @@ -19,282 +19,240 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" -#: ../../en/get-started/first-graph.md:7 +#: ../../en/get-started/first-graph.md:8 msgid "Get Started" msgstr "快速开始" -#: ../../en/get-started/first-graph.md:10 +#: ../../en/get-started/first-graph.md:11 msgid "Run your first graph" msgstr "运行第一个图" -#: ../../en/get-started/first-graph.md:13 +#: ../../en/get-started/first-graph.md:14 msgid "" -"Load a complete experiment, run it on CPU, verify the recorded result, and " -"change one variable without rewriting a training script." +"Load a complete experiment, run it on CPU, verify the recorded result, " +"and change one variable without rewriting a training script." msgstr "加载完整实验,在 CPU 上运行,验证记录结果,并在不重写训练脚本的情况下修改一个变量。" -#: ../../en/get-started/first-graph.md:17 +#: ../../en/get-started/first-graph.md:18 msgid "Before you begin" msgstr "开始前准备" -#: ../../en/get-started/first-graph.md:19 +#: ../../en/get-started/first-graph.md:20 msgid "" -"Complete [Install and run](index.md), keep the backend running, and confirm " -"`http://127.0.0.1:8042/api/health` returns `\"ok\": true`." +"Complete [Install and run](index.md), keep the backend running, and " +"confirm `http://127.0.0.1:8042/api/health` returns `\"ok\": true`." msgstr "" "完成[安装并运行](index.md),保持后端运行,并确认 `http://127.0.0.1:8042/api/health` 返回 " "`\"ok\": true`。" -#: ../../en/get-started/first-graph.md:22 +#: ../../en/get-started/first-graph.md:23 msgid "" "This tutorial uses the bundled `Edge of Stability (CPU)` template. It " -"contains a synthetic linear dataset, an MLP, MSE loss, SGD, a Trainer, and " -"an Observable. The run is small enough for CPU and does not download a " -"dataset." +"contains a synthetic linear dataset, an MLP, MSE loss, SGD, a Trainer, " +"and an Observable. The run is small enough for CPU and does not download " +"a dataset." msgstr "" -"本教程使用内置的 `Edge of Stability (CPU)` 模板,其中包含合成线性数据集、MLP、MSE loss、SGD、Trainer 和" -" Observable。该实验规模足够小,可在 CPU 上运行,并且不需要下载数据集。" +"本教程使用内置的 `Edge of Stability (CPU)` 模板,其中包含合成线性数据集、MLP、MSE " +"loss、SGD、Trainer 和 Observable。该实验规模足够小,可在 CPU 上运行,并且不需要下载数据集。" -#: ../../en/get-started/first-graph.md:26 +#: ../../en/get-started/first-graph.md:27 msgid "1. Load the template" msgstr "第一步:加载实验模板" -#: ../../en/get-started/first-graph.md:28 +#: ../../en/get-started/first-graph.md:29 msgid "Select **Templates** in the left rail." msgstr "在左侧栏选择 **Templates**。" -#: ../../en/get-started/first-graph.md:29 +#: ../../en/get-started/first-graph.md:30 msgid "Open **Edge of Stability (CPU)**." msgstr "打开 **Edge of Stability (CPU)**。" -#: ../../en/get-started/first-graph.md:30 +#: ../../en/get-started/first-graph.md:31 msgid "Wait for its project and saved graph to appear on the canvas." msgstr "等待其项目和已保存图出现在画布中。" -#: ../../en/get-started/first-graph.md:32 -msgid "Product screenshot pending · IMG-02" -msgstr "产品截图待补 · IMG-02" +#: ../../en/get-started/first-graph.md:33 +msgid "Templates view with the recommended first-run graph template selected." +msgstr "模板视图,已选中推荐的首次运行图模板。" -#: ../../en/get-started/first-graph.md:35 -msgid "**Purpose:** Show where the first-run template is selected." -msgstr "**目的:** 展示首次运行时在哪里选择模板。" - -#: ../../en/get-started/first-graph.md -msgid "Capture specification" -msgstr "截图说明" - -#: ../../en/get-started/first-graph.md:41 -msgid "" -"**Capture:** Templates view with `Edge of Stability (CPU)` selected in a " -"clean demo project." -msgstr "**Capture:** 在干净的演示项目中打开 Templates 视图,并选中 `Edge of Stability (CPU)`。" - -#: ../../en/get-started/first-graph.md:43 -msgid "**Final file:** `docs/en/_images/app/first-graph-template.png`" -msgstr "**最终文件:** `docs/en/_images/app/first-graph-template.png`" - -#: ../../en/get-started/first-graph.md:47 +#: ../../en/get-started/first-graph.md:38 msgid "2. Read the graph before running it" msgstr "第二步:运行前先查看计算图" -#: ../../en/get-started/first-graph.md:49 +#: ../../en/get-started/first-graph.md:40 msgid "" -"Find the **Trainer** node and confirm that **compute device** is **CPU**. " -"Its Dataset, MLP, MSE, and SGD inputs should already be connected. The graph" -" itself is the executable specification: node fields become parameters and " -"edges tell the backend which values satisfy each Trainer input." +"Find the **Trainer** node and confirm that **compute device** is **CPU**." +" Its Dataset, MLP, MSE, and SGD inputs should already be connected. The " +"graph itself is the executable specification: node fields become " +"parameters and edges tell the backend which values satisfy each Trainer " +"input." msgstr "" -"找到 **Trainer** Node,并确认 **compute device** 为 **CPU**。其 Dataset、MLP、MSE 和 SGD" -" 输入应已连接。图本身就是可执行规格:Node 字段会成为参数,边会告知后端哪些值满足每个 Trainer 输入。" +"找到 **Trainer** Node,并确认 **compute device** 为 **CPU**。其 Dataset、MLP、MSE 和 " +"SGD 输入应已连接。图本身就是可执行规格:Node 字段会成为参数,边会告知后端哪些值满足每个 Trainer 输入。" -#: ../../en/get-started/first-graph.md:54 +#: ../../en/get-started/first-graph.md:45 msgid "" -"The template is configured for 80 steps. It records every step so the edge-" -"of-stability dynamics stay visible; note its `trainingSteps`, " +"The template is configured for 80 steps. It records every step so the " +"edge-of-stability dynamics stay visible; note its `trainingSteps`, " "`logFrequency`, and `batchSize` values before changing anything." msgstr "" "该模板配置为运行 80 步,并记录每一步,以便清楚展示稳定性边缘动态。在修改任何设置之前,请先记下 " "`trainingSteps`、`logFrequency` 和 `batchSize` 的值。" -#: ../../en/get-started/first-graph.md:58 -msgid "Product screenshot pending · IMG-03" -msgstr "产品截图待补 · IMG-03" - -#: ../../en/get-started/first-graph.md:61 -msgid "**Purpose:** Make the minimum valid Trainer wiring and CPU setting visible." -msgstr "**目的:** 清楚展示最小有效的 Trainer 连线和 CPU 设置。" - -#: ../../en/get-started/first-graph.md:67 -msgid "" -"**Capture:** Loaded graph with the Trainer selected, its inputs connected, " -"and CPU selected." -msgstr "**截图:** 展示已加载的图,选中 Trainer、其输入已连接并选择 CPU。" - -#: ../../en/get-started/first-graph.md:70 -msgid "**Final file:** `docs/en/_images/app/first-graph-trainer.png`" -msgstr "**最终文件:** `docs/en/_images/app/first-graph-trainer.png`" +#: ../../en/get-started/first-graph.md:49 +msgid "A connected Trainer node configured to run the example graph on CPU." +msgstr "一个已连接的 Trainer 节点,配置为在 CPU 上运行示例图。" -#: ../../en/get-started/first-graph.md:74 +#: ../../en/get-started/first-graph.md:54 msgid "3. Run and inspect the result" msgstr "第三步:运行并查看结果" -#: ../../en/get-started/first-graph.md:76 +#: ../../en/get-started/first-graph.md:56 msgid "Click **Train** in the Trainer header." msgstr "在 Trainer 标题栏中点击 **Train**。" -#: ../../en/get-started/first-graph.md:77 +#: ../../en/get-started/first-graph.md:57 msgid "Watch the Trainer progress state while the backend executes the graph." msgstr "后端执行图时,观察 Trainer 进度状态。" -#: ../../en/get-started/first-graph.md:78 +#: ../../en/get-started/first-graph.md:58 msgid "" -"When the run completes, inspect **Training viz** and the paired Observable " -"visualization." +"When the run completes, inspect **Training viz** and the paired " +"Observable visualization." msgstr "运行完成后,检查 **Training viz** 和配套的 Observable 可视化。" -#: ../../en/get-started/first-graph.md:80 +#: ../../en/get-started/first-graph.md:60 msgid "Check that the recorded loss has a history rather than a single value." msgstr "确认记录的损失具有历史,而不是单个值。" -#: ../../en/get-started/first-graph.md:82 +#: ../../en/get-started/first-graph.md:62 msgid "" "This is a qualitative CPU demonstration of the edge-of-stability (EoS) " "result in [Damian, Nichani, and Lee " -"(2023)](https://arxiv.org/abs/2209.15594), not a numerical reproduction of " -"its CIFAR-10 experiments. The Hessian visualization uses its dashed `2/η = " -"10` line as the stability cutoff. In a successful run:" +"(2023)](https://arxiv.org/abs/2209.15594), not a numerical reproduction " +"of its CIFAR-10 experiments. The Hessian visualization uses its dashed " +"`2/η = 10` line as the stability cutoff. In a successful run:" msgstr "" "这是对 [Damian、Nichani 和 " "Lee(2023)](https://arxiv.org/abs/2209.15594)稳定性边缘(EoS)结果的定性 CPU 演示,并非对其 " "CIFAR-10 实验的数值复现。Hessian 可视化使用虚线 `2/η = 10` 作为稳定性阈值。成功运行时:" -#: ../../en/get-started/first-graph.md:87 -msgid "`λ₁` rises to the cutoff, briefly overshoots it, then fluctuates around it;" +#: ../../en/get-started/first-graph.md:67 +msgid "" +"`λ₁` rises to the cutoff, briefly overshoots it, then fluctuates around " +"it;" msgstr "`λ₁` 上升到阈值,短暂超过阈值,随后在阈值附近波动;" -#: ../../en/get-started/first-graph.md:88 +#: ../../en/get-started/first-graph.md:68 msgid "" -"`λ₂` remains well below the cutoff, so the visible instability is confined " -"to the top direction; and" +"`λ₂` remains well below the cutoff, so the visible instability is " +"confined to the top direction; and" msgstr "`λ₂` 始终显著低于阈值,因此可见的不稳定性仅限于最大特征值方向;并且" -#: ../../en/get-started/first-graph.md:90 +#: ../../en/get-started/first-graph.md:70 msgid "the loss can rise temporarily after the cutoff, but falls overall." msgstr "loss 在越过阈值后可能暂时上升,但总体仍呈下降趋势。" -#: ../../en/get-started/first-graph.md:92 +#: ../../en/get-started/first-graph.md:72 msgid "" -"The Hessian Observable demonstrates that measurements are graph components. " -"It records at the Trainer's logging cadence and does not replace the loss " -"being optimized. Remove it later when a faster loss-only smoke test is more " -"useful." +"The Hessian Observable demonstrates that measurements are graph " +"components. It records at the Trainer's logging cadence and does not " +"replace the loss being optimized. Remove it later when a faster loss-only" +" smoke test is more useful." msgstr "" -"Hessian Observable 表明测量也是图组件。它按 Trainer 的记录节奏采样,不会替代正在优化的损失。若更快的仅损失 smoke " -"test 更有用,可稍后移除它。" - -#: ../../en/get-started/first-graph.md:97 -msgid "Product screenshot pending · IMG-04" -msgstr "产品截图待补 · IMG-04" - -#: ../../en/get-started/first-graph.md:100 -msgid "**Purpose:** Give the tutorial an unmistakable visual success state." -msgstr "**目的:** 为本教程提供明确无误的视觉成功状态。" +"Hessian Observable 表明测量也是图组件。它按 Trainer 的记录节奏采样,不会替代正在优化的损失。若更快的仅损失 smoke" +" test 更有用,可稍后移除它。" -#: ../../en/get-started/first-graph.md:106 -msgid "" -"**Capture:** Completed Trainer state with a loss history and one Observable " -"visualization visible." -msgstr "**截图:** 展示已完成的 Trainer 状态、损失历史和一个可见的 Observable 可视化。" - -#: ../../en/get-started/first-graph.md:109 -msgid "**Final file:** `docs/en/_images/app/first-graph-results.png`" -msgstr "**最终文件:** `docs/en/_images/app/first-graph-results.png`" +#: ../../en/get-started/first-graph.md:77 +msgid "Completed example run with a recorded Observable displayed as a chart." +msgstr "已完成的示例运行,记录的 Observable 以图表形式显示。" -#: ../../en/get-started/first-graph.md:113 +#: ../../en/get-started/first-graph.md:82 msgid "Success checkpoints" msgstr "成功检查点" -#: ../../en/get-started/first-graph.md:115 +#: ../../en/get-started/first-graph.md:84 msgid "The first run is complete when all of these are true:" msgstr "满足以下所有条件时,首次运行即完成:" -#: ../../en/get-started/first-graph.md:117 +#: ../../en/get-started/first-graph.md:86 msgid "the Trainer reaches the end of its 80 steps without an error state;" msgstr "Trainer 无错误地完成全部 80 步;" -#: ../../en/get-started/first-graph.md:118 +#: ../../en/get-started/first-graph.md:87 msgid "Training viz contains a loss history;" msgstr "Training viz 包含损失历史;" -#: ../../en/get-started/first-graph.md:119 +#: ../../en/get-started/first-graph.md:88 msgid "" -"the paired Observable visualization contains `λ₁` and `λ₂`, plus its `2/η = " -"10` reference line;" +"the paired Observable visualization contains `λ₁` and `λ₂`, plus its `2/η" +" = 10` reference line;" msgstr "配套的 Observable 可视化包含 `λ₁`、`λ₂` 以及 `2/η = 10` 参考线;" -#: ../../en/get-started/first-graph.md:121 +#: ../../en/get-started/first-graph.md:90 msgid "the graph is still editable after the run." msgstr "运行后图仍可编辑。" -#: ../../en/get-started/first-graph.md:123 +#: ../../en/get-started/first-graph.md:92 msgid "" -"The qualitative relationship is the scientific checkpoint; exact crossing " -"steps and amplitudes are not universal pass criteria because EoS is " -"sensitive to numerical perturbations. The paper uses a substantially larger " -"setup and higher precision after instability, whereas this graph is " -"deliberately small enough for local CPU execution." +"The qualitative relationship is the scientific checkpoint; exact crossing" +" steps and amplitudes are not universal pass criteria because EoS is " +"sensitive to numerical perturbations. The paper uses a substantially " +"larger setup and higher precision after instability, whereas this graph " +"is deliberately small enough for local CPU execution." msgstr "" "这里的科学检查点是定性关系。由于 EoS " "对数值扰动敏感,越过阈值的确切步数和振幅并不是通用的通过标准。论文采用了规模大得多的配置,并在出现不稳定性后使用更高精度;本图则刻意保持较小规模,以便在本地" " CPU 上运行。" -#: ../../en/get-started/first-graph.md:129 +#: ../../en/get-started/first-graph.md:98 msgid "Make one controlled change" msgstr "进行一次受控改动" -#: ../../en/get-started/first-graph.md:131 +#: ../../en/get-started/first-graph.md:100 msgid "" "Change the SGD learning rate from `0.2` to `0.1`, but leave the other " -"settings unchanged. Run the Trainer again and compare the curves: the cutoff" -" becomes `2/η = 20`, which the top eigenvalue should not reach in this short" -" CPU demo, and the loss should be more nearly monotonic. This is the " -"negative control for the EoS interpretation." +"settings unchanged. Run the Trainer again and compare the curves: the " +"cutoff becomes `2/η = 20`, which the top eigenvalue should not reach in " +"this short CPU demo, and the loss should be more nearly monotonic. This " +"is the negative control for the EoS interpretation." msgstr "" -"将 SGD learning rate 从 `0.2` 改为 `0.1`,其他设置保持不变。再次运行 Trainer 并比较曲线:阈值会变为 `2/η " -"= 20`;在这个简短的 CPU 演示中,最大特征值不应达到该阈值,loss 也应更接近单调下降。这是用于验证 EoS 解读的负对照。" +"将 SGD learning rate 从 `0.2` 改为 `0.1`,其他设置保持不变。再次运行 Trainer 并比较曲线:阈值会变为 " +"`2/η = 20`;在这个简短的 CPU 演示中,最大特征值不应达到该阈值,loss 也应更接近单调下降。这是用于验证 EoS 解读的负对照。" -#: ../../en/get-started/first-graph.md:137 +#: ../../en/get-started/first-graph.md:106 msgid "That small action is the core research loop:" msgstr "这一小步就是核心研究循环:" -#: ../../en/get-started/first-graph.md:139 +#: ../../en/get-started/first-graph.md:108 msgid "keep the experimental structure visible;" msgstr "保持实验结构可见;" -#: ../../en/get-started/first-graph.md:140 +#: ../../en/get-started/first-graph.md:109 msgid "change one variable;" msgstr "修改一个变量;" -#: ../../en/get-started/first-graph.md:141 +#: ../../en/get-started/first-graph.md:110 msgid "record the resulting dynamics;" msgstr "记录由此产生的动态;" -#: ../../en/get-started/first-graph.md:142 +#: ../../en/get-started/first-graph.md:111 msgid "preserve enough context to explain the comparison." msgstr "保留足以解释比较的上下文。" -#: ../../en/get-started/first-graph.md:144 +#: ../../en/get-started/first-graph.md:113 msgid "" "When the graph is useful, save a **Small** template. Small retains graph " -"structure and settings while excluding plot histories and checkpoint bytes. " -"The [Projects and artifacts](../user-guide/projects-and-artifacts.md) guide " -"explains the larger save tiers and their costs." +"structure and settings while excluding plot histories and checkpoint " +"bytes. The [Projects and artifacts](../user-guide/projects-and-" +"artifacts.md) guide explains the larger save tiers and their costs." msgstr "" -"当该图可复用时,保存为 **Small** 模板。Small 保留图结构和设置,但不包含绘图历史和检查点字节。[项目和产物](../user-guide" -"/projects-and-artifacts.md)指南说明了更大保存层级及其成本。" +"当该图可复用时,保存为 **Small** 模板。Small 保留图结构和设置,但不包含绘图历史和检查点字节。[项目和产物](../user-" +"guide/projects-and-artifacts.md)指南说明了更大保存层级及其成本。" -#: ../../en/get-started/first-graph.md:149 +#: ../../en/get-started/first-graph.md:118 msgid "" "Next, learn how to [build and run graphs](../user-guide/build-and-run-" "graphs.md) without starting from a complete template." msgstr "接下来,学习如何在不从完整模板开始的情况下[构建并运行图](../user-guide/build-and-run-graphs.md)。" + diff --git a/docs/locales/zh_CN/LC_MESSAGES/reference/data-contracts.po b/docs/locales/zh_CN/LC_MESSAGES/reference/data-contracts.po index 7107375..2b3318a 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/reference/data-contracts.po +++ b/docs/locales/zh_CN/LC_MESSAGES/reference/data-contracts.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-27 19:58+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: zh_CN\n" @@ -192,6 +192,204 @@ msgid "" msgstr "作为规范文件名时,Template ID 不得为空,也不得包含斜杠、反斜杠或以点开头。" #: ../../en/reference/data-contracts.md:69 +msgid "Run store (`data/runs/`)" +msgstr "运行存储(`data/runs/`)" + +#: ../../en/reference/data-contracts.md:71 +msgid "" +"Each training run started through `POST /api/train` or `POST /api/runs` " +"gets its own directory, keyed by `run_id`:" +msgstr "每个通过 `POST /api/train` 或 `POST /api/runs` 启动的训练运行都拥有自己的目录,以 `run_id` 为键:" + +#: ../../en/reference/data-contracts.md:85 +#, python-brace-format +msgid "" +"`run.json` is written atomically (`write_run_record`) on every status " +"transition. `metrics.ndjson` is append-only: each line is one delta row " +"(`{\"idx\", \"loss\", ...}`), covering only the metric indices not " +"already written, so appends stay O(1) instead of re-writing the whole " +"cumulative history on every event. `results.json` is written once, at the" +" run's terminal event." +msgstr "" +"`run.json` 在每次状态转换时原子写入(`write_run_record`)。`metrics.ndjson` " +"只追加:每行是一条增量行(`{\"idx\", \"loss\", ...}`),只覆盖尚未写入的指标索引,因此追加保持 " +"O(1),而不是在每个事件上重写整个累积历史。`results.json` 只在运行的终止事件时写入一次。" + +#: ../../en/reference/data-contracts.md:92 +msgid "Metrics authority rule" +msgstr "指标权威规则" + +#: ../../en/reference/data-contracts.md:94 +#, python-brace-format +msgid "" +"`results.json` wins over `metrics.ndjson` whenever it is present: " +"`load_series()` returns `results.json`'s content if the file exists and " +"parses, otherwise it rebuilds the series (`loss_history`, " +"`test_loss_history`, `reg_loss_history`, `step_ticks`, `epoch_ticks`) " +"from `metrics.ndjson`. `GET /api/runs/{run_id}/metrics` reports which " +"source won as `\"source\": \"results\" | \"ndjson\"`. This is the single " +"rule used everywhere a run's metrics are read: the API, index rebuild, " +"and stale-run reconciliation all call the same `load_series()` function, " +"so they never disagree about which file is authoritative." +msgstr "" +"只要 `results.json` 存在,它就优先于 `metrics.ndjson`:`load_series()` " +"在该文件存在且能解析时返回其内容,否则从 `metrics.ndjson` " +"重建序列(`loss_history`、`test_loss_history`、`reg_loss_history`、`step_ticks`、`epoch_ticks`)。`GET" +" /api/runs/{run_id}/metrics` 会以 `\"source\": \"results\" | \"ndjson\"` " +"报告哪个来源生效。这是读取运行指标时到处使用的唯一规则:API、索引重建和过期运行的调解都调用同一个 `load_series()` " +"函数,因此它们永远不会在哪个文件是权威来源上产生分歧。" + +#: ../../en/reference/data-contracts.md:104 +msgid "`index.db` is rebuildable, never authoritative" +msgstr "`index.db` 可重建,从不是权威来源" + +#: ../../en/reference/data-contracts.md:106 +msgid "" +"`data/runs/index.db` is a SQLite index over the fields needed to list, " +"filter, and sort runs quickly. It is derived entirely from the `run.json`" +" and metrics files in each run directory, and `rebuild_index()` in " +"`comfy_research/engine/runs/run_index.py` can fully regenerate it at any " +"time by replaying every `data/runs/run-*/run.json`. A `run.json` that " +"fails to parse is not skipped during rebuild: it is indexed with a " +"synthetic `unreadable` status so it stays visible (and deletable) instead" +" of disappearing from listings." +msgstr "" +"`data/runs/index.db` 是围绕列出、过滤和排序运行所需字段建立的 SQLite 索引。它完全派生自每个运行目录中的 " +"`run.json` 和指标文件,`comfy_research/engine/runs/run_index.py` 中的 " +"`rebuild_index()` 可以在任何时候通过重放每个 `data/runs/run-*/run.json` 来完全重新生成它。解析失败的" +" `run.json` 在重建期间不会被跳过:它会以合成的 `unreadable` 状态被索引,使其保持可见(且可删除),而不是从列表中消失。" + +#: ../../en/reference/data-contracts.md:115 +msgid "" +"`rebuild_index()` is wired into the server startup lifespan " +"(`comfy_research/main.py`), which detects two recovery cases and rebuilds" +" automatically before serving traffic: a missing or corrupt `index.db` (a" +" `sqlite3.DatabaseError` opening it), and an index whose row count is " +"lower than the number of `run-*/run.json` directories actually on disk. " +"It also runs on demand from `scripts/rebuild_run_index.py`. What runs " +"automatically after every async-submitted run finishes, in addition to " +"startup, is `reconcile_stale_running()` (marks stuck `running`/`queued` " +"rows `crashed`) and the retention GC; both of those read and update " +"existing index rows rather than rebuilding the whole file. Never hand-" +"edit `index.db` directly either way: any edit not reflected in the " +"corresponding `run.json` is discarded the next time the index is rebuilt," +" and in the meantime it only leaves the index and the files it's supposed" +" to mirror disagreeing." +msgstr "" +"`rebuild_index()` " +"已接入服务器启动生命周期(`comfy_research/main.py`),它会检测两种恢复场景并在开始提供服务前自动重建:`index.db`" +" 缺失或损坏(打开时抛出 `sqlite3.DatabaseError`),以及索引行数低于磁盘上实际存在的 `run-*/run.json` " +"目录数量。它也可以通过 `scripts/rebuild_run_index.py` " +"按需运行。除了启动时之外,每个异步提交的运行结束后自动运行的是 `reconcile_stale_running()`(将卡住的 " +"`running`/`queued` 行标记为 `crashed`)和保留 " +"GC;这两者都是读取并更新现有索引行,而不是重建整个文件。无论哪种情况都不要直接手工编辑 `index.db`:任何未反映在对应 " +"`run.json` 中的编辑,都会在索引下次重建时被丢弃,同时它只会让索引与它本应镜像的文件之间产生分歧。" + +#: ../../en/reference/data-contracts.md:129 +msgid "What is never persisted" +msgstr "从不持久化的内容" + +#: ../../en/reference/data-contracts.md:131 +msgid "" +"The run store keeps only training-loop metrics and lightweight metadata. " +"The following are stripped before anything is written to `data/runs/`:" +msgstr "运行存储只保留训练循环指标和轻量元数据。以下内容会在写入 `data/runs/` 之前被剥离:" + +#: ../../en/reference/data-contracts.md:134 +msgid "" +"**Checkpoints** (`checkpoint_b64` is stripped from both the graph " +"snapshot in `run.json` (`RUN_RESULT_DATA_KEYS`) and the terminal payload " +"in `results.json` (`_RESULT_STRIP_KEYS`); it never appears in " +"`metrics.ndjson` because that file only ever receives per-step metric " +"rows, not checkpoint data). `memoryCheckpoint_b64`, the " +"`model_checkpoint` node's separate memory-checkpoint field used on both " +"local and remote runs, is a different key, stripped only from the " +"`run.json` graph snapshot (also via `RUN_RESULT_DATA_KEYS`); it is not " +"part of `_RESULT_STRIP_KEYS`, but it also never appears in the trainer's " +"terminal event payload in the first place, so it does not reach " +"`results.json` either way." +msgstr "" +"**检查点**(`checkpoint_b64` 会同时从 `run.json` 中的图快照(`RUN_RESULT_DATA_KEYS`)和 " +"`results.json` 中的终止负载(`_RESULT_STRIP_KEYS`)中剥离;它永远不会出现在 `metrics.ndjson` " +"中,因为该文件只接收逐步指标行,不接收检查点数据)。`memoryCheckpoint_b64`,即 `model_checkpoint` " +"节点在本地和远程运行中都会用到的独立内存检查点字段,是另一个键,只会从 `run.json` 图快照中剥离(同样通过 " +"`RUN_RESULT_DATA_KEYS`);它不属于 `_RESULT_STRIP_KEYS`,但它本来就从不出现在 trainer " +"的终止事件负载中,因此无论如何都不会到达 `results.json`。" + +#: ../../en/reference/data-contracts.md:145 +msgid "" +"**Rendered images**: `plot_png_base64` and related preview/plot PNG " +"payloads." +msgstr "**渲染图像**:`plot_png_base64` 及相关的预览/绘图 PNG 负载。" + +#: ../../en/reference/data-contracts.md:147 +msgid "" +"**Embedding and attention histories**: `observable_embedding_histories`, " +"`observable_attention_slice_histories`, and per-node " +"`embeddingHistory`/`attentionMapFrames` UI blobs." +msgstr "" +"**嵌入与注意力历史**:`observable_embedding_histories`、`observable_attention_slice_histories`,以及每个节点的" +" `embeddingHistory`/`attentionMapFrames` UI 数据块。" + +#: ../../en/reference/data-contracts.md:150 +msgid "" +"Other UI-only result fields the frontend stashes on node `data` " +"(loss/test histories, tick arrays, run summaries, last-error text; the " +"full list is `RUN_RESULT_DATA_KEYS` in " +"`comfy_research/schemas/run_record.py`) are stripped from the graph " +"snapshot stored in `run.json` (`strip_result_data`) and from " +"`results.json` (`_RESULT_STRIP_KEYS` in " +"`comfy_research/engine/runs/run_store.py`)." +msgstr "" +"前端暂存在节点 `data` 上的其他仅供 UI 使用的结果字段(损失/测试历史、tick 数组、运行摘要、末次错误文本;完整列表见 " +"`comfy_research/schemas/run_record.py` 中的 `RUN_RESULT_DATA_KEYS`)会从存储在 " +"`run.json` 中的图快照中剥离(`strip_result_data`),也会从 `results.json` " +"中剥离(`comfy_research/engine/runs/run_store.py` 中的 `_RESULT_STRIP_KEYS`)。" + +#: ../../en/reference/data-contracts.md:157 +msgid "" +"`run.json` stores a config-only graph snapshot: node `data` with these " +"result/UI keys removed, so the run's inputs are reproducible without " +"carrying the run's outputs back into the graph document." +msgstr "" +"`run.json` 存储的是仅含配置的图快照:节点 `data` 中已移除这些结果/UI " +"键,因此运行的输入可复现,而不会把运行的输出带回图文档中。" + +#: ../../en/reference/data-contracts.md:161 +msgid "`paused` is terminal in the run store" +msgstr "`paused` 在运行存储中是终止状态" + +#: ../../en/reference/data-contracts.md:163 +msgid "" +"`paused` is one of the run store's terminal statuses (`TERMINAL_STATUSES`" +" includes it alongside `completed`, `failed`, `aborted`, and `crashed`): " +"a paused run will not accept further metric events and is eligible for " +"deletion like any other finished run. Resuming a paused run does not " +"reopen it: the client submits a **new** run whose `TrainRequest.resume` " +"carries the checkpoint state from the `paused` event, and whose " +"`run_parent_id` is set to the paused run's `run_id`. The new run's " +"`RunRecord.parent_id` records that link, so a resume chain can be traced " +"back through `parent_id` without the store ever mutating a terminal run " +"in place." +msgstr "" +"`paused` 是运行存储的终止状态之一(`TERMINAL_STATUSES` 将它与 " +"`completed`、`failed`、`aborted` 和 `crashed` " +"并列):已暂停的运行不再接受后续指标事件,并且和其他已完成运行一样可以删除。恢复一个已暂停的运行并不会重新打开它:客户端会提交一个**新**运行,其" +" `TrainRequest.resume` 携带来自 `paused` 事件的检查点状态,其 `run_parent_id` 设为被暂停运行的 " +"`run_id`。新运行的 `RunRecord.parent_id` 记录这一链接,因此恢复链可以通过 `parent_id` " +"追溯,而运行存储从不在原地修改一个终止运行。" + +#: ../../en/reference/data-contracts.md:173 +msgid "" +"Source: `comfy_research/engine/runs/run_store.py`, " +"`comfy_research/engine/runs/run_index.py`, " +"`comfy_research/schemas/run_record.py`. Route contracts: [Runs API](runs-" +"api.md)." +msgstr "" +"来源:`comfy_research/engine/runs/run_store.py`、`comfy_research/engine/runs/run_index.py`、`comfy_research/schemas/run_record.py`。路由契约:[运行" +" API](runs-api.md)。" + +#: ../../en/reference/data-contracts.md:178 msgid "Export tiers" msgstr "导出层级" @@ -229,8 +427,9 @@ msgstr "大" msgid "Return the full graph document unchanged" msgstr "原样返回完整图文档" -#: ../../en/reference/data-contracts.md:77 +#: ../../en/reference/data-contracts.md:186 msgid "" "The tier describes filtering, not confidentiality. Inspect all documents " "before publishing them." msgstr "层级描述的是过滤,而非保密性。发布前请检查所有文档。" + diff --git a/docs/locales/zh_CN/LC_MESSAGES/reference/index.po b/docs/locales/zh_CN/LC_MESSAGES/reference/index.po index 43a0185..baf9944 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/reference/index.po +++ b/docs/locales/zh_CN/LC_MESSAGES/reference/index.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-24 14:05+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: zh_CN\n" @@ -19,23 +19,27 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" -#: ../../en/reference/index.md:27 ../../en/reference/index.md:63 +#: ../../en/reference/index.md:27 ../../en/reference/index.md:70 msgid "Application" msgstr "应用" -#: ../../en/reference/index.md:34 ../../en/reference/index.md:63 +#: ../../en/reference/index.md:34 ../../en/reference/index.md:70 msgid "Training API" msgstr "训练 API" -#: ../../en/reference/index.md:41 ../../en/reference/index.md:63 +#: ../../en/reference/index.md:41 ../../en/reference/index.md:70 msgid "Data contracts" msgstr "数据契约" -#: ../../en/reference/index.md:48 ../../en/reference/index.md:63 +#: ../../en/reference/index.md:48 ../../en/reference/index.md:70 +msgid "Runs API" +msgstr "运行 API" + +#: ../../en/reference/index.md:55 ../../en/reference/index.md:70 msgid "Node contracts" msgstr "Node 契约" -#: ../../en/reference/index.md:55 ../../en/reference/index.md:63 +#: ../../en/reference/index.md:62 ../../en/reference/index.md:70 msgid "Support status" msgstr "支持状态" @@ -49,21 +53,24 @@ msgstr "运行时与文件契约" #: ../../en/reference/index.md:13 msgid "" -"Exact commands, routes, document versions, generated artifacts, and support " -"boundaries for the current development source." +"Exact commands, routes, document versions, generated artifacts, and " +"support boundaries for the current development source." msgstr "当前开发源码的确切命令、路由、文档版本、生成产物和支持边界。" #: ../../en/reference/index.md:17 msgid "" -"Use these pages to look up a contract. For task sequences, return to the [User Guide" -"](../user-guide/index.md). The interactive FastAPI schema at `/docs` lists every " -"development endpoint; this reference concentrates on the supported stable workflow." +"Use these pages to look up a contract. For task sequences, return to the " +"[User Guide](../user-guide/index.md). The interactive FastAPI schema at " +"`/docs` lists every development endpoint; this reference concentrates on " +"the supported stable workflow." msgstr "" -"使用这些页面查阅契约。对于任务步骤,请返回[用户指南](../user-guide/index.md)。`/docs` 中的交互式 FastAPI schema " -"列出每个开发端点;本参考集中说明受支持的稳定工作流。" +"使用这些页面查阅契约。对于任务步骤,请返回[用户指南](../user-guide/index.md)。`/docs` 中的交互式 FastAPI" +" schema 列出每个开发端点;本参考集中说明受支持的稳定工作流。" #: ../../en/reference/index.md:31 -msgid "CLI options, environment variables, remote precedence, and local state paths." +msgid "" +"CLI options, environment variables, remote precedence, and local state " +"paths." msgstr "CLI 选项、环境变量、远程配置优先级和本地状态路径。" #: ../../en/reference/index.md:38 @@ -75,9 +82,16 @@ msgid "Graph, workspace, and saved-library document shapes and versions." msgstr "图、工作区和已保存库文档的结构及版本。" #: ../../en/reference/index.md:52 +msgid "Async run submission, lifecycle, metrics, and retention." +msgstr "异步运行提交、生命周期、指标和保留策略。" + +#: ../../en/reference/index.md:59 msgid "Definition sources, generated outputs, and validation commands." msgstr "定义来源、生成输出和验证命令。" -#: ../../en/reference/index.md:59 -msgid "Stable documentation scope, experimental areas, and development-version policy." +#: ../../en/reference/index.md:66 +msgid "" +"Stable documentation scope, experimental areas, and development-version " +"policy." msgstr "稳定文档范围、实验性领域和开发版本策略。" + diff --git a/docs/locales/zh_CN/LC_MESSAGES/reference/runs-api.po b/docs/locales/zh_CN/LC_MESSAGES/reference/runs-api.po new file mode 100644 index 0000000..b0b99fe --- /dev/null +++ b/docs/locales/zh_CN/LC_MESSAGES/reference/runs-api.po @@ -0,0 +1,723 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) +# This file is distributed under the same license as the Comfy Research +# package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Comfy Research \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language: zh_CN\n" +"Language-Team: zh_CN \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: ../../en/reference/runs-api.md:7 +msgid "Reference" +msgstr "参考" + +#: ../../en/reference/runs-api.md:10 +msgid "Runs API" +msgstr "运行 API" + +#: ../../en/reference/runs-api.md:12 +#, python-brace-format +msgid "" +"The run store persists every training run started through `POST " +"/api/train` or `POST /api/runs` under `data/runs/{run_id}/`. The routes " +"below query and manage that store. Files on disk are the source of truth;" +" `data/runs/index.db` is a rebuildable read index (see [Data contracts" +"](data-contracts.md) for the on-disk layout)." +msgstr "" +"运行存储会将每个通过 `POST /api/train` 或 `POST /api/runs` 启动的训练运行持久化到 " +"`data/runs/{run_id}/` 下。以下路由用于查询和管理该存储。磁盘上的文件是唯一真相来源;`data/runs/index.db`" +" 是一个可重建的只读索引(磁盘布局参见[数据契约](data-contracts.md))。" + +#: ../../en/reference/runs-api.md:19 +#, python-brace-format +msgid "" +"CRL (curriculum-reinforcement) trainer runs, submitted through streaming " +"`POST /api/train` for a `crl_trainer` node, are not captured by the run " +"store at all: no `run.json` is written and no run ID is returned, so " +"these runs never appear in `GET /api/runs`, `GET /api/runs/{run_id}`, or " +"the index." +msgstr "" +"通过流式 `POST /api/train` 为 `crl_trainer`(课程强化)节点提交的运行,完全不会被运行存储捕获:不会写入 " +"`run.json`,也不会返回运行 ID,因此这些运行永远不会出现在 `GET /api/runs`、`GET " +"/api/runs/{run_id}` 或索引中。" + +#: ../../en/reference/runs-api.md:24 +msgid "Async submit contract" +msgstr "异步提交契约" + +#: ../../en/reference/runs-api.md:26 +msgid "" +"`POST /api/runs` accepts the same `TrainRequest` body as `POST " +"/api/train` but does not stream. It returns immediately with `202 " +"Accepted` and the run executes detached from the HTTP request:" +msgstr "" +"`POST /api/runs` 接受与 `POST /api/train` 相同的 `TrainRequest` " +"请求体,但不进行流式传输。它会立即返回 `202 Accepted`,运行与该 HTTP 请求分离执行:" + +#: ../../en/reference/runs-api.md:6 +msgid "Behavior" +msgstr "行为" + +#: ../../en/reference/runs-api.md:6 +msgid "Contract" +msgstr "契约" + +#: ../../en/reference/runs-api.md:6 +msgid "Response" +msgstr "响应" + +#: ../../en/reference/runs-api.md:6 +#, python-brace-format +msgid "`202` with `{\"run_id\", \"status\"}` (`status` is `\"queued\"`)" +msgstr "`202`,携带 `{\"run_id\", \"status\"}`(`status` 为 `\"queued\"`)" + +#: ../../en/reference/runs-api.md:6 +msgid "`Idempotency-Key` header" +msgstr "`Idempotency-Key` 请求头" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"Optional. Repeating the same key while the first submission is in flight " +"(or after it has been recorded) returns the same run instead of starting " +"a second one." +msgstr "可选。在第一次提交仍在进行中(或已被记录之后)重复使用同一个键,会返回同一个运行,而不会启动第二个。" + +#: ../../en/reference/runs-api.md:6 +msgid "Remote GPU trainer" +msgstr "远程 GPU trainer" + +#: ../../en/reference/runs-api.md:6 +#, python-brace-format +msgid "" +"`400` with `{\"detail\": {\"code\": \"remote_not_supported\", \"detail\":" +" \"...\"}}` (see the error envelope section below). Async submit only " +"runs locally; use the streaming `POST /api/train` for a trainer node " +"configured with `computeDevice: \"cuda\"` and `remoteGpu: true`." +msgstr "" +"`400`,携带 `{\"detail\": {\"code\": \"remote_not_supported\", \"detail\": " +"\"...\"}}`(见下方错误信封小节)。异步提交只能在本地运行;对于配置了 `computeDevice: \"cuda\"` 和 " +"`remoteGpu: true` 的 trainer 节点,请使用流式 `POST /api/train`。" + +#: ../../en/reference/runs-api.md:6 +msgid "Serialization" +msgstr "串行化" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"Runs submitted for the same `trainer_node_id` execute one at a time, " +"FIFO, because the underlying trainer control registry holds one slot per " +"trainer ID. Runs for different trainer IDs execute concurrently up to " +"`worker_slots`." +msgstr "" +"为同一个 `trainer_node_id` 提交的运行会按 FIFO 顺序逐一执行,因为底层的 trainer 控制注册表为每个 trainer" +" ID 只保留一个槽位。不同 trainer ID 的运行可以并发执行,最多到 `worker_slots` 个。" + +#: ../../en/reference/runs-api.md:37 +msgid "Routes" +msgstr "路由" + +#: ../../en/reference/runs-api.md:6 +msgid "Method and path" +msgstr "方法和路径" + +#: ../../en/reference/runs-api.md:6 +msgid "`POST /api/runs`" +msgstr "`POST /api/runs`" + +#: ../../en/reference/runs-api.md:6 +msgid "Submit a run for async execution. See above." +msgstr "提交一个运行以异步执行。见上文。" + +#: ../../en/reference/runs-api.md:6 +msgid "`GET /api/runs`" +msgstr "`GET /api/runs`" + +#: ../../en/reference/runs-api.md:6 +#, python-brace-format +msgid "" +"List runs. Query params below. Returns `{\"runs\": [...], " +"\"next_cursor\"}`." +msgstr "列出运行。查询参数见下文。返回 `{\"runs\": [...], \"next_cursor\"}`。" + +#: ../../en/reference/runs-api.md:6 +msgid "`GET /api/runs/groups`" +msgstr "`GET /api/runs/groups`" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"Summarize runs by `group_id`: per-group status counts, best test loss, " +"best final loss." +msgstr "按 `group_id` 汇总运行:每组的状态计数、最佳测试损失、最佳最终损失。" + +#: ../../en/reference/runs-api.md:6 +#, python-brace-format +msgid "`GET /api/runs/{run_id}`" +msgstr "`GET /api/runs/{run_id}`" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"Full run record plus a `summary` (`final_loss`, `final_test_loss`, " +"`best_test_loss`, `steps_completed`) computed from the metrics authority." +" `404 run_not_found` if the run does not exist." +msgstr "" +"完整运行记录,外加一个从指标权威来源计算出的 " +"`summary`(`final_loss`、`final_test_loss`、`best_test_loss`、`steps_completed`)。若运行不存在则返回" +" `404 run_not_found`。" + +#: ../../en/reference/runs-api.md:6 ../../en/reference/runs-api.md:67 +#, python-brace-format +msgid "`GET /api/runs/{run_id}/metrics`" +msgstr "`GET /api/runs/{run_id}/metrics`" + +#: ../../en/reference/runs-api.md:6 +#, python-brace-format +msgid "" +"The metrics series for one run. See below. `404 run_not_found` if the run" +" does not exist, and also if it is `unreadable` (unlike `GET " +"/api/runs/{run_id}`, this route does not special-case unreadable runs)." +msgstr "" +"一个运行的指标序列。见下文。若运行不存在,或运行为 `unreadable`,都会返回 `404 run_not_found`(与 `GET " +"/api/runs/{run_id}` 不同,本路由不对 unreadable 运行做特殊处理)。" + +#: ../../en/reference/runs-api.md:6 +#, python-brace-format +msgid "`POST /api/runs/{run_id}/abort`" +msgstr "`POST /api/runs/{run_id}/abort`" + +#: ../../en/reference/runs-api.md:6 +#, python-brace-format +msgid "" +"Cooperatively abort a run that was submitted through `POST /api/runs` " +"(the async worker pool). Returns `{\"ok\": true}` if a cancellation was " +"issued, `{\"ok\": false}` if the worker pool has no active or queued " +"entry for this ID (either because the run is already terminal, or because" +" it was never submitted through `POST /api/runs` in the first place, e.g." +" it is a streaming `POST /api/train` run; the worker pool doesn't track " +"those). `404 run_not_found` if the run does not exist at all, and also if" +" it is `unreadable`. To abort a run started through streaming `POST " +"/api/train`, use `POST /api/train/control` with its `trainer_node_id` " +"instead." +msgstr "" +"协作式地中止一个通过 `POST /api/runs`(异步 worker 池)提交的运行。若已发出取消指令则返回 `{\"ok\": " +"true}`;若 worker 池中没有该 ID 对应的活跃或排队条目,则返回 `{\"ok\": " +"false}`(原因可能是该运行已经处于终止状态,也可能是它一开始就不是通过 `POST /api/runs` 提交的,例如它是一个流式 " +"`POST /api/train` 运行,worker 池不会追踪这类运行)。若运行完全不存在,或运行为 `unreadable`,都会返回 " +"`404 run_not_found`。要中止通过流式 `POST /api/train` 启动的运行,请改用 `POST " +"/api/train/control`,并携带其 `trainer_node_id`。" + +#: ../../en/reference/runs-api.md:6 +#, python-brace-format +msgid "`DELETE /api/runs/{run_id}`" +msgstr "`DELETE /api/runs/{run_id}`" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"Delete one run's directory and index row. `409 run_active` if the run is " +"not in a terminal or `unreadable` state (abort it first). `404 " +"run_not_found` if the run does not exist." +msgstr "" +"删除一个运行的目录和索引行。若运行不处于终止状态或 `unreadable` 状态,则返回 `409 " +"run_active`(请先中止它)。若运行不存在则返回 `404 run_not_found`。" + +#: ../../en/reference/runs-api.md:6 +msgid "`DELETE /api/runs`" +msgstr "`DELETE /api/runs`" + +#: ../../en/reference/runs-api.md:6 +#, python-brace-format +msgid "" +"Bulk delete. Requires at least one filter; see below. Returns " +"`{\"deleted\": }`." +msgstr "批量删除。至少需要一个过滤器;见下文。返回 `{\"deleted\": }`。" + +#: ../../en/reference/runs-api.md:50 +msgid "List query params (`GET /api/runs`, and filters for `DELETE /api/runs`)" +msgstr "列表查询参数(`GET /api/runs`,以及 `DELETE /api/runs` 的过滤器)" + +#: ../../en/reference/runs-api.md:6 +msgid "Param" +msgstr "参数" + +#: ../../en/reference/runs-api.md:6 +msgid "Meaning" +msgstr "含义" + +#: ../../en/reference/runs-api.md:6 +msgid "`status`" +msgstr "`status`" + +#: ../../en/reference/runs-api.md:6 +msgid "Exact match on run status." +msgstr "精确匹配运行状态。" + +#: ../../en/reference/runs-api.md:6 +msgid "`origin`" +msgstr "`origin`" + +#: ../../en/reference/runs-api.md:6 +msgid "Exact match on `human`, `agent`, or `sweep`." +msgstr "精确匹配 `human`、`agent` 或 `sweep`。" + +#: ../../en/reference/runs-api.md:6 +msgid "`group_id`" +msgstr "`group_id`" + +#: ../../en/reference/runs-api.md:6 +msgid "Exact match on group ID." +msgstr "精确匹配组 ID。" + +#: ../../en/reference/runs-api.md:6 +msgid "`since`" +msgstr "`since`" + +#: ../../en/reference/runs-api.md:6 +msgid "Unix time in milliseconds; only runs created at or after this time." +msgstr "以毫秒为单位的 Unix 时间;只返回在此时间及之后创建的运行。" + +#: ../../en/reference/runs-api.md:6 +msgid "`ids`" +msgstr "`ids`" + +#: ../../en/reference/runs-api.md:6 +msgid "Comma-separated list of run IDs." +msgstr "以逗号分隔的运行 ID 列表。" + +#: ../../en/reference/runs-api.md:6 +#, python-brace-format +msgid "`hyperparam.{node_id}.{field}`" +msgstr "`hyperparam.{node_id}.{field}`" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"Filter on a flattened hyperparameter value, e.g. " +"`hyperparam.optimizer.learningRate=0.01`. Compared as text." +msgstr "按展平后的超参数值过滤,例如 `hyperparam.optimizer.learningRate=0.01`。按文本比较。" + +#: ../../en/reference/runs-api.md:6 +msgid "`order_by`" +msgstr "`order_by`" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"One of `created_at`, `finished_at`, `final_loss`, `final_test_loss`, " +"`best_test_loss`, `steps_completed`, `duration_seconds`. Prefix with `-` " +"for descending (default: `-created_at`). An unrecognized column falls " +"back to `-created_at`." +msgstr "" +"取值为 " +"`created_at`、`finished_at`、`final_loss`、`final_test_loss`、`best_test_loss`、`steps_completed`、`duration_seconds`" +" 之一。前缀 `-` 表示降序(默认:`-created_at`)。无法识别的列会回退到 `-created_at`。" + +#: ../../en/reference/runs-api.md:6 +msgid "`limit`" +msgstr "`limit`" + +#: ../../en/reference/runs-api.md:6 +msgid "Page size, default `100`, clamped to `1..500`." +msgstr "分页大小,默认 `100`,限制在 `1..500` 之间。" + +#: ../../en/reference/runs-api.md:6 +msgid "`cursor`" +msgstr "`cursor`" + +#: ../../en/reference/runs-api.md:6 +msgid "Opaque cursor from a previous page's `next_cursor`." +msgstr "来自上一页 `next_cursor` 的不透明游标。" + +#: ../../en/reference/runs-api.md:64 +msgid "" +"Cursor pagination is keyset-based (not offset), so it stays correct while" +" new runs are inserted. `next_cursor` is `null` on the last page." +msgstr "游标分页基于 keyset(而非偏移量),因此在插入新运行期间仍能保持正确。最后一页的 `next_cursor` 为 `null`。" + +#: ../../en/reference/runs-api.md:6 +msgid "`downsample`" +msgstr "`downsample`" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"If set and a series is longer than this value, stride-sample it down to " +"roughly this many points. Response includes `\"downsampled\": true` when " +"any series was reduced." +msgstr "" +"若设置该参数,且某个序列长度超过此值,则按步幅采样,降到大致这么多个点。若有任何序列被缩减,响应会包含 `\"downsampled\": " +"true`。" + +#: ../../en/reference/runs-api.md:73 +#, python-brace-format +msgid "" +"Response shape: `{\"source\": \"results\" | \"ndjson\", \"data\": {...}, " +"\"downsampled\": bool}`. `source` reports which store backed the " +"response; see the authority rule in [Data contracts](data-contracts.md)." +msgstr "" +"响应结构:`{\"source\": \"results\" | \"ndjson\", \"data\": {...}, " +"\"downsampled\": bool}`。`source` 报告响应由哪个存储支撑;权威规则参见[数据契约](data-" +"contracts.md)。" + +#: ../../en/reference/runs-api.md:77 +msgid "Status lifecycle" +msgstr "状态生命周期" + +#: ../../en/reference/runs-api.md:6 +msgid "Status" +msgstr "状态" + +#: ../../en/reference/runs-api.md:6 +msgid "`queued`" +msgstr "`queued`" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"Submitted, not yet started (worker pool is busy with another run on the " +"same trainer, or the run has not been dispatched yet)." +msgstr "已提交,尚未开始(worker 池正忙于同一个 trainer 上的另一个运行,或该运行尚未被派发)。" + +#: ../../en/reference/runs-api.md:6 +msgid "`running`" +msgstr "`running`" + +#: ../../en/reference/runs-api.md:6 +msgid "Executing." +msgstr "执行中。" + +#: ../../en/reference/runs-api.md:6 +msgid "`completed`" +msgstr "`completed`" + +#: ../../en/reference/runs-api.md:6 +msgid "Finished normally (`complete` NDJSON event)." +msgstr "正常结束(`complete` NDJSON 事件)。" + +#: ../../en/reference/runs-api.md:6 +msgid "`failed`" +msgstr "`failed`" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"Errored (`error` NDJSON event, an unhandled exception during execution, " +"or a validation failure at submit time)." +msgstr "出错(`error` NDJSON 事件、执行期间未处理的异常,或提交时的校验失败)。" + +#: ../../en/reference/runs-api.md:6 +msgid "`aborted`" +msgstr "`aborted`" + +#: ../../en/reference/runs-api.md:6 +#, python-brace-format +msgid "" +"Stopped cooperatively (`POST /api/runs/{run_id}/abort`, `POST " +"/api/train/control`, or the client disconnecting mid-stream)." +msgstr "" +"被协作式停止(`POST /api/runs/{run_id}/abort`、`POST " +"/api/train/control`,或客户端在流式传输过程中断开连接)。" + +#: ../../en/reference/runs-api.md:6 +msgid "`paused`" +msgstr "`paused`" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"Stopped with resumable state. Terminal in the run store: resuming starts " +"a **new** run whose `TrainRequest.resume` carries the checkpoint and " +"whose `run_parent_id` points back at this run. See [Data contracts](data-" +"contracts.md)." +msgstr "" +"以可恢复状态停止。在运行存储中是终止状态:恢复会启动一个**新**运行,其 `TrainRequest.resume` 携带检查点,其 " +"`run_parent_id` 指回这个运行。参见[数据契约](data-contracts.md)。" + +#: ../../en/reference/runs-api.md:6 +msgid "`crashed`" +msgstr "`crashed`" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"Reconciliation status. Any run still `running` or `queued` at server " +"startup is marked `crashed` with `finished_at` set (nothing can " +"legitimately still be executing in a fresh process). During normal " +"operation, a run that stays `running` or `queued` past a heartbeat " +"timeout (no progress event for 60 seconds) with no terminal event ever " +"recorded, e.g. the server process died mid-run, is also marked `crashed`." +" Both cases run through `reconcile_stale_running()`; it is not periodic " +"and it is not the same operation as rebuilding `index.db`." +msgstr "" +"调解状态。服务器启动时仍处于 `running` 或 `queued` 的任何运行都会被标记为 `crashed` 并设置 " +"`finished_at`(一个全新进程中不可能真的还有运行在执行)。在正常运行期间,若一个运行在心跳超时(60 秒内没有 progress " +"事件)之后仍停留在 `running` 或 `queued`,且从未记录过终止事件,例如服务器进程在运行中途崩溃,同样会被标记为 " +"`crashed`。这两种情况都通过 `reconcile_stale_running()` 处理;它不是周期性运行的,也不同于重建 " +"`index.db` 的操作。" + +#: ../../en/reference/runs-api.md:6 +msgid "`unreadable`" +msgstr "`unreadable`" + +#: ../../en/reference/runs-api.md:6 +#, python-brace-format +msgid "" +"Not a stored status value; a synthetic state returned by the API/index " +"when `run.json` exists on disk but fails to parse. Unreadable runs are " +"deletable (`DELETE /api/runs/{run_id}` and bulk delete both treat " +"`unreadable` as eligible) so they can be cleared without hand-editing the" +" store." +msgstr "" +"不是一个存储的状态值;当磁盘上存在 `run.json` 但解析失败时,由 API/索引返回的合成状态。unreadable " +"运行可以删除(`DELETE /api/runs/{run_id}` 和批量删除都将 `unreadable` " +"视为符合条件),因此可以在不手工编辑存储的情况下清除它们。" + +#: ../../en/reference/runs-api.md:94 +msgid "" +"`completed`, `failed`, `aborted`, `paused`, and `crashed` are terminal: " +"the run store will not accept further metric events for them." +msgstr "" +"`completed`、`failed`、`aborted`、`paused` 和 `crashed` " +"都是终止状态:运行存储不会再为它们接受后续指标事件。" + +#: ../../en/reference/runs-api.md:97 +msgid "Error envelope" +msgstr "错误信封" + +#: ../../en/reference/runs-api.md:99 +#, python-brace-format +msgid "" +"Every error response from `/api/runs*` is a FastAPI `HTTPException`, so " +"the JSON response body nests the structured error under the top-level " +"`detail` key that FastAPI always wraps `HTTPException.detail` in: the " +"response is `{\"detail\": {\"code\": ..., \"detail\": ...}}`, not a flat " +"object:" +msgstr "" +"`/api/runs*` 的每个错误响应都是一个 FastAPI `HTTPException`,因此 JSON 响应体会将结构化错误嵌套在 " +"FastAPI 始终用来包裹 `HTTPException.detail` 的顶层 `detail` 键下:响应是 `{\"detail\": " +"{\"code\": ..., \"detail\": ...}}`,而不是一个扁平对象:" + +#: ../../en/reference/runs-api.md:6 +msgid "`code`" +msgstr "`code`" + +#: ../../en/reference/runs-api.md:6 +msgid "When" +msgstr "何时" + +#: ../../en/reference/runs-api.md:6 +msgid "`run_not_found`" +msgstr "`run_not_found`" + +#: ../../en/reference/runs-api.md:6 +msgid "404" +msgstr "404" + +#: ../../en/reference/runs-api.md:6 +msgid "The run ID does not exist." +msgstr "该运行 ID 不存在。" + +#: ../../en/reference/runs-api.md:6 +msgid "`invalid_run_id`" +msgstr "`invalid_run_id`" + +#: ../../en/reference/runs-api.md:6 +msgid "400" +msgstr "400" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"The `run_id` path parameter is not a well-formed run ID (e.g. it contains" +" a path separator)." +msgstr "`run_id` 路径参数不是格式良好的运行 ID(例如它包含路径分隔符)。" + +#: ../../en/reference/runs-api.md:6 +msgid "`invalid_cursor`" +msgstr "`invalid_cursor`" + +#: ../../en/reference/runs-api.md:6 +msgid "The `cursor` query parameter on `GET /api/runs` could not be parsed." +msgstr "`GET /api/runs` 上的 `cursor` 查询参数无法解析。" + +#: ../../en/reference/runs-api.md:6 +msgid "`invalid_query_param`" +msgstr "`invalid_query_param`" + +#: ../../en/reference/runs-api.md:6 +msgid "`since` is not a valid number, or `limit` is not a valid integer." +msgstr "`since` 不是一个有效数字,或 `limit` 不是一个有效整数。" + +#: ../../en/reference/runs-api.md:6 +msgid "`remote_not_supported`" +msgstr "`remote_not_supported`" + +#: ../../en/reference/runs-api.md:6 +msgid "`POST /api/runs` was called for a trainer node configured for remote GPU." +msgstr "`POST /api/runs` 被用于一个配置了远程 GPU 的 trainer 节点。" + +#: ../../en/reference/runs-api.md:6 +msgid "`run_active`" +msgstr "`run_active`" + +#: ../../en/reference/runs-api.md:6 +msgid "409" +msgstr "409" + +#: ../../en/reference/runs-api.md:6 +#, python-brace-format +msgid "" +"`DELETE /api/runs/{run_id}` was called on a run that is not terminal or " +"`unreadable`." +msgstr "`DELETE /api/runs/{run_id}` 被用于一个既非终止状态也非 `unreadable` 的运行。" + +#: ../../en/reference/runs-api.md:6 +msgid "`filter_required`" +msgstr "`filter_required`" + +#: ../../en/reference/runs-api.md:6 +msgid "`DELETE /api/runs` was called with no filter at all." +msgstr "`DELETE /api/runs` 被调用时完全没有携带过滤器。" + +#: ../../en/reference/runs-api.md:118 +msgid "Bulk delete filter requirement" +msgstr "批量删除过滤器要求" + +#: ../../en/reference/runs-api.md:120 +msgid "" +"`DELETE /api/runs` refuses to run with zero filters: it always requires " +"at least one of `status`, `origin`, `group_id`, `since`, `ids`, or a " +"`hyperparam.*` filter, so an empty query can never wipe the entire store." +" Given filters, it paginates through every matching row, deletes those " +"whose status is terminal or `unreadable`, and skips any active runs the " +"filter also matched (it does not abort them)." +msgstr "" +"`DELETE /api/runs` 拒绝在零过滤器的情况下运行:它总是要求至少携带 " +"`status`、`origin`、`group_id`、`since`、`ids` 或一个 `hyperparam.*` " +"过滤器之一,因此一个空查询永远无法清空整个存储。给定过滤器后,它会分页遍历每一行匹配结果,删除状态为终止或 `unreadable` " +"的行,并跳过过滤器同时匹配到的任何活跃运行(不会中止它们)。" + +#: ../../en/reference/runs-api.md:127 +msgid "Garbage collection" +msgstr "垃圾回收" + +#: ../../en/reference/runs-api.md:129 +msgid "" +"A retention sweep for `origin=agent` and `origin=sweep` runs at server " +"startup and again after every async-submitted run finishes execution. " +"Configuration is read from `data/runs/config.json`; any keys not present " +"fall back to defaults:" +msgstr "" +"针对 `origin=agent` 和 `origin=sweep` " +"的保留清理会在服务器启动时运行一次,并在每个异步提交的运行结束执行后再次运行。配置从 `data/runs/config.json` " +"读取;未出现的键会回退到默认值:" + +#: ../../en/reference/runs-api.md:6 +msgid "Key" +msgstr "键" + +#: ../../en/reference/runs-api.md:6 +msgid "Default" +msgstr "默认值" + +#: ../../en/reference/runs-api.md:6 +msgid "`max_runs_agent`" +msgstr "`max_runs_agent`" + +#: ../../en/reference/runs-api.md:6 +msgid "`2000`" +msgstr "`2000`" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"Keep at most this many `agent`-origin runs (newest first); older terminal" +" runs past this cap are eligible for pruning." +msgstr "最多保留这么多个 `agent` 来源的运行(最新优先);超过此上限的较旧终止运行可被清理。" + +#: ../../en/reference/runs-api.md:6 +msgid "`max_age_days_agent`" +msgstr "`max_age_days_agent`" + +#: ../../en/reference/runs-api.md:6 +msgid "`None` (disabled)" +msgstr "`None`(禁用)" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"If set, also prune terminal `agent`-origin runs older than this many " +"days, even within the cap." +msgstr "若设置该值,还会清理超过这么多天的 `agent` 来源终止运行,即便未超出数量上限。" + +#: ../../en/reference/runs-api.md:6 +msgid "`max_runs_sweep`" +msgstr "`max_runs_sweep`" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"Same cap and grace/terminal rules as `max_runs_agent`, applied to " +"`sweep`-origin runs (the highest-volume producer, since each sweep or " +"coordinate-descent session writes one run per evaluated point)." +msgstr "" +"与 `max_runs_agent` 相同的上限以及宽限期/终止状态规则,应用于 `sweep` 来源的运行(数量最大的产生者,因为每次 " +"sweep 或坐标下降会话都会为每个评估点写入一个运行)。" + +#: ../../en/reference/runs-api.md:6 +msgid "`worker_slots`" +msgstr "`worker_slots`" + +#: ../../en/reference/runs-api.md:6 +msgid "`2`" +msgstr "`2`" + +#: ../../en/reference/runs-api.md:6 +msgid "" +"Number of runs the async worker pool can execute concurrently (across " +"distinct `trainer_node_id`s). Read once at process start." +msgstr "异步 worker 池可以并发执行的运行数量(跨不同的 `trainer_node_id`)。只在进程启动时读取一次。" + +#: ../../en/reference/runs-api.md:141 +msgid "" +"Only runs that are terminal, have a recorded `finished_at`, and finished " +"more than 10 minutes ago are eligible for pruning; this grace period " +"keeps a just-finished run visible before GC can remove it. GC never " +"touches `human` origin runs." +msgstr "" +"只有处于终止状态、记录了 `finished_at`,且结束超过 10 分钟的运行才符合清理条件;这个宽限期能让刚结束的运行在被 GC " +"移除之前仍保持可见。GC 从不触碰 `human` 来源的运行。" + +#: ../../en/reference/runs-api.md:146 +msgid "Known limitations" +msgstr "已知限制" + +#: ../../en/reference/runs-api.md:148 +msgid "" +"The `train_control` registry that backs pause and abort signals holds one" +" slot per `trainer_node_id`, not one per run. Avoid running the same " +"graph concurrently through both `POST /api/runs` (the async worker pool) " +"and a streaming `POST /api/train` request: if two runs share a " +"`trainer_node_id` while both are active, a pause or abort signal is " +"routed by trainer ID and can land on the wrong run." +msgstr "" +"支撑暂停和中止信号的 `train_control` 注册表为每个 `trainer_node_id` " +"只保留一个槽位,而不是每个运行一个。请避免同时通过 `POST /api/runs`(异步 worker 池)和流式 `POST " +"/api/train` 请求并发运行同一张图:如果两个运行共享同一个 `trainer_node_id` 且都处于活跃状态,暂停或中止信号会按 " +"trainer ID 路由,可能会落到错误的运行上。" + +#: ../../en/reference/runs-api.md:155 +msgid "curl example" +msgstr "curl 示例" + +#: ../../en/reference/runs-api.md:157 +msgid "" +"Submit a minimal CPU run, poll it, then fetch its metrics. The body shape" +" below is the same one used by `minimal_cpu_train_request` in the test " +"suite (`comfy_research/tests/train_test_fixtures.py`): a two-layer MLP " +"trained for three steps on a tiny synthetic linear dataset." +msgstr "" +"提交一个最小化的 CPU " +"运行,轮询它,然后获取它的指标。下方的请求体结构与测试套件(`comfy_research/tests/train_test_fixtures.py`)中" +" `minimal_cpu_train_request` 所使用的相同:一个两层 MLP,在一个微型合成线性数据集上训练三步。" + diff --git a/docs/locales/zh_CN/LC_MESSAGES/reference/training-api.po b/docs/locales/zh_CN/LC_MESSAGES/reference/training-api.po index 06f4180..2f3d201 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/reference/training-api.po +++ b/docs/locales/zh_CN/LC_MESSAGES/reference/training-api.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-24 14:05+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: zh_CN\n" @@ -29,8 +29,8 @@ msgstr "训练 API" #: ../../en/reference/training-api.md:12 msgid "" -"The FastAPI development schema is available at `/docs`. The routes below are the " -"supported stable workflow surface documented here." +"The FastAPI development schema is available at `/docs`. The routes below " +"are the supported stable workflow surface documented here." msgstr "FastAPI 开发 schema 位于 `/docs`。以下路由是本文档说明的受支持稳定工作流接口。" #: ../../en/reference/training-api.md:15 @@ -50,7 +50,9 @@ msgid "`GET /api/health`" msgstr "`GET /api/health`" #: ../../en/reference/training-api.md:6 -msgid "Health, effective local or remote mode, remote source, and last validation state" +msgid "" +"Health, effective local or remote mode, remote source, and last " +"validation state" msgstr "健康状态、实际本地或远程模式、远程来源和上次验证状态" #: ../../en/reference/training-api.md:6 @@ -186,25 +188,99 @@ msgstr "在训练请求之外同步并验证远程运行时" #: ../../en/reference/training-api.md:41 msgid "" -"`TrainRequest` contains `trainer_node_id`, `nodes`, `edges`, optional `resume` state," -" and optional `hessian_oversized_policy` (`skip` or `force`). Nodes and edges are " -"validated using the graph schema before the run is prepared." +"`TrainRequest` contains `trainer_node_id`, `nodes`, `edges`, optional " +"`resume` state, and optional `hessian_oversized_policy` (`skip` or " +"`force`). Nodes and edges are validated using the graph schema before the" +" run is prepared." msgstr "" "`TrainRequest` 包含 `trainer_node_id`、`nodes`、`edges`、可选的 `resume` 状态和可选的 " "`hessian_oversized_policy`(`skip` 或 `force`)。准备运行前,节点和边会使用图 schema 验证。" #: ../../en/reference/training-api.md:45 +msgid "" +"`TrainRequest` also carries the run store's provenance fields, all " +"optional:" +msgstr "`TrainRequest` 还携带运行存储的来源字段,均为可选:" + +#: ../../en/reference/training-api.md:6 +msgid "Field" +msgstr "字段" + +#: ../../en/reference/training-api.md:6 +msgid "Type" +msgstr "类型" + +#: ../../en/reference/training-api.md:6 +msgid "Meaning" +msgstr "含义" + +#: ../../en/reference/training-api.md:6 +msgid "`run_origin`" +msgstr "`run_origin`" + +#: ../../en/reference/training-api.md:6 +msgid "`human`, `agent`, or `sweep`" +msgstr "`human`、`agent` 或 `sweep`" + +#: ../../en/reference/training-api.md:6 +msgid "" +"Default `human`. Determines which runs the agent-origin GC sweep and " +"`origin` query filters see; see [Runs API](runs-api.md)." +msgstr "" +"默认 `human`。决定 agent 来源的 GC 清理和 `origin` 查询过滤器可见哪些运行;参见[运行 API](runs-" +"api.md)。" + +#: ../../en/reference/training-api.md:6 +msgid "`run_group_id`" +msgstr "`run_group_id`" + +#: ../../en/reference/training-api.md:6 +msgid "string or `null`" +msgstr "字符串或 `null`" + +#: ../../en/reference/training-api.md:6 +msgid "" +"Groups related runs (e.g. a sweep's inner runs) for `GET " +"/api/runs/groups` and the `group_id` query filter." +msgstr "为 `GET /api/runs/groups` 和 `group_id` 查询过滤器对相关运行分组(例如一次扫参的内部运行)。" + +#: ../../en/reference/training-api.md:6 +msgid "`run_parent_id`" +msgstr "`run_parent_id`" + +#: ../../en/reference/training-api.md:6 +msgid "" +"Set when this run resumes a `paused` run; becomes the new " +"`RunRecord.parent_id`, linking a resume chain back to the run it resumed." +" See [Data contracts](data-contracts.md)." +msgstr "" +"当此运行恢复某个 `paused` 运行时设置;成为新的 `RunRecord.parent_id`,将恢复链接回它所恢复的运行。参见[数据契约" +"](data-contracts.md)。" + +#: ../../en/reference/training-api.md:53 +msgid "" +"These fields are only meaningful when the run is captured into the run " +"store: every `POST /api/train` request (local and remote) and every `POST" +" /api/runs` submission builds a `RunRecord` from them, except a `POST " +"/api/train` request for a `crl_trainer` node, which is never captured. " +"See [Runs API](runs-api.md)." +msgstr "" +"这些字段仅在运行被捕获进运行存储时才有意义:每个 `POST /api/train` 请求(本地和远程)以及每个 `POST /api/runs`" +" 提交都会由它们构建 `RunRecord`,但针对 `crl_trainer` 节点的 `POST /api/train` " +"请求除外,它从不被捕获。参见[运行 API](runs-api.md)。" + +#: ../../en/reference/training-api.md:59 msgid "NDJSON response" msgstr "NDJSON 响应" -#: ../../en/reference/training-api.md:47 +#: ../../en/reference/training-api.md:61 msgid "" -"`POST /api/train` returns `application/x-ndjson`. Each line is a complete JSON " -"object. Clients must process the stream incrementally and must not parse the response" -" as one JSON document." +"`POST /api/train` returns `application/x-ndjson`. Each line is a complete" +" JSON object. Clients must process the stream incrementally and must not " +"parse the response as one JSON document." msgstr "" -"`POST /api/train` 返回 `application/x-ndjson`。每行都是一个完整 JSON 对象。客户端必须增量处理该流,不得将响应解析为一个 " -"JSON 文档。" +"`POST /api/train` 返回 `application/x-ndjson`。每行都是一个完整 JSON " +"对象。客户端必须增量处理该流,不得将响应解析为一个 JSON 文档。" #: ../../en/reference/training-api.md:6 msgid "Event type" @@ -214,6 +290,23 @@ msgstr "事件类型" msgid "Meaning and stable fields" msgstr "含义和稳定字段" +#: ../../en/reference/training-api.md:6 +msgid "`run_registered`" +msgstr "`run_registered`" + +#: ../../en/reference/training-api.md:6 +#, python-brace-format +msgid "" +"First event of the stream (local and remote), with `run_id`. The run has " +"been persisted to the run store (`data/runs/{run_id}/`) and can be " +"queried through the [Runs API](runs-api.md) while streaming continues. " +"Not emitted for CRL trainer runs, which are not captured into the run " +"store." +msgstr "" +"流的第一个事件(本地和远程均适用),携带 " +"`run_id`。该运行已持久化到运行存储(`data/runs/{run_id}/`),在流式传输继续期间可通过[运行 API](runs-" +"api.md)查询。CRL trainer 运行不会发出该事件,因为它们不会被捕获进运行存储。" + #: ../../en/reference/training-api.md:6 msgid "`progress`" msgstr "`progress`" @@ -236,8 +329,8 @@ msgstr "`complete`" #: ../../en/reference/training-api.md:6 msgid "" -"Terminal success with checkpoint, loss histories, ticks, visualization targets, and " -"Observable updates" +"Terminal success with checkpoint, loss histories, ticks, visualization " +"targets, and Observable updates" msgstr "终止成功,包含检查点、损失历史、ticks、可视化目标和 Observable 更新" #: ../../en/reference/training-api.md:6 @@ -246,8 +339,8 @@ msgstr "`paused`" #: ../../en/reference/training-api.md:6 msgid "" -"Terminal pause for this stream with `next_step` plus resumable checkpoint and " -"histories" +"Terminal pause for this stream with `next_step` plus resumable checkpoint" +" and histories" msgstr "此流终止暂停,包含 `next_step` 以及可恢复的检查点和历史" #: ../../en/reference/training-api.md:6 @@ -266,23 +359,26 @@ msgstr "`error`" msgid "Terminal remote-stream error with `detail`" msgstr "包含 `detail` 的终止远程流错误" -#: ../../en/reference/training-api.md:60 +#: ../../en/reference/training-api.md:75 msgid "" -"Non-finite numeric values are converted to JSON `null` before encoding so each line " -"remains valid RFC 8259 JSON." +"Non-finite numeric values are converted to JSON `null` before encoding so" +" each line remains valid RFC 8259 JSON." msgstr "编码前会将非有限数值转换为 JSON `null`,以确保每行仍是有效的 RFC 8259 JSON。" -#: ../../en/reference/training-api.md:63 +#: ../../en/reference/training-api.md:78 msgid "" -"For a normal local run the documented sequence begins with one or more `progress` " -"events and ends in `complete`, `paused`, or `aborted`. Remote runs can emit `phase` " -"before training and can terminate with `error` if the SSH process fails without " -"another terminal event." +"For a normal local run the documented sequence begins with " +"`run_registered`, then one or more `progress` events, and ends in " +"`complete`, `paused`, or `aborted`. Remote runs emit `run_registered` " +"after bootstrap and validation succeed, can emit `phase` before training," +" and can terminate with `error` if the SSH process fails without another " +"terminal event." msgstr "" -"正常本地运行的文档化序列以一个或多个 `progress` 事件开始,并以 `complete`、`paused` 或 `aborted` 结束。远程运行可在训练前发出 " -"`phase`,若 SSH 进程失败且没有其他终止事件,则可以 `error` 结束。" +"对于普通本地运行,文档化的事件序列以 `run_registered` 开始,随后是一个或多个 `progress` 事件,并以 " +"`complete`、`paused` 或 `aborted` 结束。远程运行在引导和验证成功后发出 " +"`run_registered`,可在训练前发出 `phase`,若 SSH 进程失败且没有其他终止事件,则可以 `error` 结束。" -#: ../../en/reference/training-api.md:68 +#: ../../en/reference/training-api.md:84 msgid "Sweep routes" msgstr "扫参路由" @@ -292,11 +388,11 @@ msgstr "`POST /api/train/sweep`" #: ../../en/reference/training-api.md:6 msgid "" -"Stream `sweep_started`, `sweep_progress`, `sweep_row`, then `sweep_complete` or " -"`sweep_aborted`" +"Stream `sweep_started`, `sweep_progress`, `sweep_row`, then " +"`sweep_complete` or `sweep_aborted`" msgstr "" -"依次流式传输 `sweep_started`、`sweep_progress`、`sweep_row`,然后是 `sweep_complete` 或 " -"`sweep_aborted`" +"依次流式传输 `sweep_started`、`sweep_progress`、`sweep_row`,然后是 `sweep_complete` " +"或 `sweep_aborted`" #: ../../en/reference/training-api.md:6 msgid "`POST /api/train/sweep/control`" @@ -322,8 +418,10 @@ msgstr "`POST /api/train/coordinate-descent/control`" msgid "Request coordinate-descent abort by session ID" msgstr "按会话 ID 请求中止坐标下降" -#: ../../en/reference/training-api.md:77 +#: ../../en/reference/training-api.md:93 msgid "" -"These route contracts do not define a scientific acceptance threshold. The client or " -"experiment protocol must decide what result counts as success." +"These route contracts do not define a scientific acceptance threshold. " +"The client or experiment protocol must decide what result counts as " +"success." msgstr "这些路由契约不定义科学验收阈值。客户端或实验协议必须决定何种结果算作成功。" + diff --git a/docs/locales/zh_CN/LC_MESSAGES/user-guide/build-and-run-graphs.po b/docs/locales/zh_CN/LC_MESSAGES/user-guide/build-and-run-graphs.po index d9822cc..ea80a16 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/user-guide/build-and-run-graphs.po +++ b/docs/locales/zh_CN/LC_MESSAGES/user-guide/build-and-run-graphs.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-24 14:05+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: zh_CN\n" @@ -29,8 +29,8 @@ msgstr "构建并运行图" #: ../../en/user-guide/build-and-run-graphs.md:13 msgid "" -"Create the smallest complete training graph, verify every dependency, and add " -"variation only after one configuration runs successfully." +"Create the smallest complete training graph, verify every dependency, and" +" add variation only after one configuration runs successfully." msgstr "创建最小完整训练图,验证每一项依赖,并只在一个配置成功运行后再添加变体。" #: ../../en/user-guide/build-and-run-graphs.md:17 @@ -39,8 +39,8 @@ msgstr "可运行图需要什么" #: ../../en/user-guide/build-and-run-graphs.md:19 msgid "" -"A standard training experiment centers on one Trainer. Add these sources from the " -"**Nodes** rail:" +"A standard training experiment centers on one Trainer. Add these sources " +"from the **Nodes** rail:" msgstr "标准训练实验以一个 Trainer 为中心。从 **Nodes** 栏添加以下来源:" #: ../../en/user-guide/build-and-run-graphs.md:12 @@ -112,167 +112,146 @@ msgid "Batch schedule" msgstr "批次调度" #: ../../en/user-guide/build-and-run-graphs.md:31 -msgid "Product screenshot pending · IMG-05" -msgstr "产品截图待补 · IMG-05" +msgid "Node library search beside a canvas where the selected node can be added." +msgstr "画布旁的节点库搜索,选中的节点可添加到画布中。" -#: ../../en/user-guide/build-and-run-graphs.md:34 -msgid "**Purpose:** Show how node search relates to the active canvas." -msgstr "**目的:** 展示 Node 搜索与当前画布之间的关系。" - -#: ../../en/user-guide/build-and-run-graphs.md -msgid "Capture specification" -msgstr "截图说明" - -#: ../../en/user-guide/build-and-run-graphs.md:40 -msgid "" -"**Capture:** Search the Nodes library for a stable built-in node while the target " -"canvas remains visible." -msgstr "**截图:** 在目标画布保持可见时,在 Nodes 库中搜索一个稳定的内置 Node。" - -#: ../../en/user-guide/build-and-run-graphs.md:43 -msgid "**Final file:** `docs/en/_images/app/add-node-from-library.png`" -msgstr "**最终文件:** `docs/en/_images/app/add-node-from-library.png`" - -#: ../../en/user-guide/build-and-run-graphs.md:47 +#: ../../en/user-guide/build-and-run-graphs.md:36 msgid "Connect the training core" msgstr "连接训练核心" -#: ../../en/user-guide/build-and-run-graphs.md:49 +#: ../../en/user-guide/build-and-run-graphs.md:38 msgid "Drag a Dataset, Model, Optimizer, Loss, and Trainer onto the canvas." msgstr "将 Dataset、Model、Optimizer、Loss 和 Trainer 拖到画布上。" -#: ../../en/user-guide/build-and-run-graphs.md:50 +#: ../../en/user-guide/build-and-run-graphs.md:39 msgid "Connect each source socket to the matching Trainer input." msgstr "将每个来源 socket 连接到对应的 Trainer 输入。" -#: ../../en/user-guide/build-and-run-graphs.md:51 +#: ../../en/user-guide/build-and-run-graphs.md:40 msgid "Add **Training viz** and connect the Trainer `loss` output to it." msgstr "添加 **Training viz**,并将 Trainer 的 `loss` 输出连接到它。" -#: ../../en/user-guide/build-and-run-graphs.md:52 +#: ../../en/user-guide/build-and-run-graphs.md:41 msgid "Set the Trainer to a short CPU run." msgstr "将 Trainer 设为一次短时 CPU 运行。" -#: ../../en/user-guide/build-and-run-graphs.md:53 -msgid "Read the graph from each required source into the Trainer before clicking **Train**." +#: ../../en/user-guide/build-and-run-graphs.md:42 +msgid "" +"Read the graph from each required source into the Trainer before clicking" +" **Train**." msgstr "点击 **Train** 前,请从每个必需来源沿图读取到 Trainer。" -#: ../../en/user-guide/build-and-run-graphs.md:56 +#: ../../en/user-guide/build-and-run-graphs.md:45 msgid "" -"The connection handles are typed. A line that looks close to an input is not enough: " -"the serialized edge must carry the intended `sourceHandle` and `targetHandle`. The " -"backend rejects missing inputs and incompatible graph components before running a " -"partial experiment." +"The connection handles are typed. A line that looks close to an input is " +"not enough: the serialized edge must carry the intended `sourceHandle` " +"and `targetHandle`. The backend rejects missing inputs and incompatible " +"graph components before running a partial experiment." msgstr "" "连接 handle 带有类型。仅看似靠近输入的连线并不够:序列化边必须携带预期的 `sourceHandle` 和 " "`targetHandle`。后端会在运行不完整实验前拒绝缺少输入和不兼容的图组件。" -#: ../../en/user-guide/build-and-run-graphs.md:61 -msgid "Product screenshot pending · IMG-06" -msgstr "产品截图待补 · IMG-06" - -#: ../../en/user-guide/build-and-run-graphs.md:64 -msgid "**Purpose:** Show the minimum stable training system as one connected graph." -msgstr "**目的:** 将最小稳定训练系统展示为一张已连接的图。" - -#: ../../en/user-guide/build-and-run-graphs.md:70 +#: ../../en/user-guide/build-and-run-graphs.md:50 msgid "" -"**Capture:** Dataset, Model, Optimizer, Loss, and Trainer with readable socket " -"connections and little empty canvas." -msgstr "**截图:** 展示 Dataset、Model、Optimizer、Loss 和 Trainer,socket 连线清晰且画布留白较少。" - -#: ../../en/user-guide/build-and-run-graphs.md:73 -msgid "**Final file:** `docs/en/_images/app/stable-training-core.png`" -msgstr "**最终文件:** `docs/en/_images/app/stable-training-core.png`" +"Complete training graph connecting data, model, optimizer, loss, and " +"Trainer nodes." +msgstr "连接数据、模型、优化器、损失和 Trainer 节点的完整训练图。" -#: ../../en/user-guide/build-and-run-graphs.md:77 +#: ../../en/user-guide/build-and-run-graphs.md:55 msgid "Configure one run" msgstr "配置一次运行" -#: ../../en/user-guide/build-and-run-graphs.md:79 +#: ../../en/user-guide/build-and-run-graphs.md:57 msgid "Start with values that are cheap to inspect:" msgstr "先使用便于检查的值:" -#: ../../en/user-guide/build-and-run-graphs.md:81 -msgid "use steps rather than epochs until dataset size and batching are understood;" +#: ../../en/user-guide/build-and-run-graphs.md:59 +msgid "" +"use steps rather than epochs until dataset size and batching are " +"understood;" msgstr "在了解数据集大小和批处理前,使用 steps 而不是 epochs;" -#: ../../en/user-guide/build-and-run-graphs.md:82 +#: ../../en/user-guide/build-and-run-graphs.md:60 msgid "use CPU unless the graph specifically requires an accelerator;" msgstr "除非图明确需要加速器,否则使用 CPU;" -#: ../../en/user-guide/build-and-run-graphs.md:83 +#: ../../en/user-guide/build-and-run-graphs.md:61 msgid "choose a log frequency that yields enough points to diagnose the curve;" msgstr "选择能产生足够点数以诊断曲线的日志频率;" -#: ../../en/user-guide/build-and-run-graphs.md:84 +#: ../../en/user-guide/build-and-run-graphs.md:62 msgid "use `-1` batch size only when full-batch training is intentional;" msgstr "仅在有意进行全批次训练时使用 `-1` batch size;" -#: ../../en/user-guide/build-and-run-graphs.md:85 +#: ../../en/user-guide/build-and-run-graphs.md:63 msgid "leave gradient clipping at zero unless clipping is part of the method." msgstr "除非裁剪是方法的一部分,否则将梯度裁剪保持为零。" -#: ../../en/user-guide/build-and-run-graphs.md:87 +#: ../../en/user-guide/build-and-run-graphs.md:65 msgid "" -"Click **Train**. During a local run the Trainer can request pause or abort. A " -"completed run sends the model checkpoint, loss histories, and Observable histories " -"through separate output channels." -msgstr "点击 **Train**。本地运行期间 Trainer 可以请求暂停或中止。已完成的运行会通过独立输出通道发送模型检查点、损失历史和 Observable 历史。" +"Click **Train**. During a local run the Trainer can request pause or " +"abort. A completed run sends the model checkpoint, loss histories, and " +"Observable histories through separate output channels." +msgstr "" +"点击 **Train**。本地运行期间 Trainer 可以请求暂停或中止。已完成的运行会通过独立输出通道发送模型检查点、损失历史和 " +"Observable 历史。" -#: ../../en/user-guide/build-and-run-graphs.md:91 +#: ../../en/user-guide/build-and-run-graphs.md:69 msgid "Add a parameter series carefully" msgstr "谨慎添加参数序列" -#: ../../en/user-guide/build-and-run-graphs.md:93 +#: ../../en/user-guide/build-and-run-graphs.md:71 msgid "" -"Supported numeric controls accept comma-separated values. When more than one field " -"has multiple values, Comfy Research builds the Cartesian product of the sweep axes. " -"Two learning rates and three batch sizes therefore produce six runs, not three." +"Supported numeric controls accept comma-separated values. When more than " +"one field has multiple values, Comfy Research builds the Cartesian " +"product of the sweep axes. Two learning rates and three batch sizes " +"therefore produce six runs, not three." msgstr "" -"支持的数值控件接受逗号分隔的值。当多个字段具有多个值时,Comfy Research 会构建扫参轴的笛卡尔积。因此,两个学习率和三个 batch size " -"会产生六次运行,而不是三次。" +"支持的数值控件接受逗号分隔的值。当多个字段具有多个值时,Comfy Research 会构建扫参轴的笛卡尔积。因此,两个学习率和三个 batch " +"size 会产生六次运行,而不是三次。" -#: ../../en/user-guide/build-and-run-graphs.md:98 +#: ../../en/user-guide/build-and-run-graphs.md:76 msgid "" -"Begin with one varying axis and hold seeds, data, measurements, and training length " -"fixed. Verify that its runs are interpretable before adding a second axis. The " -"application caps a train series at 256 combinations, but a smaller scientifically " -"justified sweep is usually better than using the technical maximum." +"Begin with one varying axis and hold seeds, data, measurements, and " +"training length fixed. Verify that its runs are interpretable before " +"adding a second axis. The application caps a train series at 256 " +"combinations, but a smaller scientifically justified sweep is usually " +"better than using the technical maximum." msgstr "" "先从一个变化轴开始,并固定随机种子、数据、测量和训练长度。添加第二个轴前,先验证其运行结果可解释。应用将训练序列限制为 256 " "种组合,但较小且科学上合理的扫参通常优于使用技术上限。" -#: ../../en/user-guide/build-and-run-graphs.md:104 +#: ../../en/user-guide/build-and-run-graphs.md:82 msgid "Verify the result" msgstr "验证结果" -#: ../../en/user-guide/build-and-run-graphs.md:106 +#: ../../en/user-guide/build-and-run-graphs.md:84 msgid "Before saving, confirm that:" msgstr "保存前,确认:" -#: ../../en/user-guide/build-and-run-graphs.md:108 +#: ../../en/user-guide/build-and-run-graphs.md:86 msgid "the Trainer reached its terminal completed state;" msgstr "Trainer 已达到终止完成状态;" -#: ../../en/user-guide/build-and-run-graphs.md:109 +#: ../../en/user-guide/build-and-run-graphs.md:87 msgid "Training viz received the expected number of logged points;" msgstr "Training viz 收到了预期数量的记录点;" -#: ../../en/user-guide/build-and-run-graphs.md:110 +#: ../../en/user-guide/build-and-run-graphs.md:88 msgid "every attached Observable produced the intended result channel;" msgstr "每个附加的 Observable 都产生了预期的结果通道;" -#: ../../en/user-guide/build-and-run-graphs.md:111 +#: ../../en/user-guide/build-and-run-graphs.md:89 msgid "the graph still shows the exact configuration that produced the result;" msgstr "图仍显示产生该结果的确切配置;" -#: ../../en/user-guide/build-and-run-graphs.md:112 +#: ../../en/user-guide/build-and-run-graphs.md:90 msgid "every sweep axis and fixed control is identifiable." msgstr "每个扫参轴和固定控件都可识别。" -#: ../../en/user-guide/build-and-run-graphs.md:114 +#: ../../en/user-guide/build-and-run-graphs.md:92 msgid "" "Next, decide which measurements belong in the graph in [Record " "Observables](observables.md)." msgstr "接下来,在[记录 Observable](observables.md)中决定哪些测量应包含在图内。" + diff --git a/docs/locales/zh_CN/LC_MESSAGES/user-guide/observables.po b/docs/locales/zh_CN/LC_MESSAGES/user-guide/observables.po index 4ec8b93..7d57a70 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/user-guide/observables.po +++ b/docs/locales/zh_CN/LC_MESSAGES/user-guide/observables.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-24 14:05+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: zh_CN\n" @@ -29,8 +29,8 @@ msgstr "记录 Observable" #: ../../en/user-guide/observables.md:13 msgid "" -"Measure training state at deliberate checkpoints without changing the loss " -"that drives optimization." +"Measure training state at deliberate checkpoints without changing the " +"loss that drives optimization." msgstr "在经过审慎选择的检查点测量训练状态,不改变驱动优化的损失。" #: ../../en/user-guide/observables.md:17 @@ -40,9 +40,9 @@ msgstr "Observable、结果与可视化" #: ../../en/user-guide/observables.md:19 msgid "" "An Observable defines what to measure. The Trainer records it at logging " -"points. A paired visualization consumes the recorded history. Keeping those " -"roles separate prevents a plotted quantity from being mistaken for an " -"optimization objective." +"points. A paired visualization consumes the recorded history. Keeping " +"those roles separate prevents a plotted quantity from being mistaken for " +"an optimization objective." msgstr "Observable 定义测量什么。Trainer 在记录点采集它,配套可视化使用记录的历史。保持这些角色分离,可避免把绘制的量误认为优化目标。" #: ../../en/user-guide/observables.md:24 @@ -62,102 +62,87 @@ msgid "Run a short graph and check that values reach that visualization." msgstr "运行一张短图,并检查数值是否到达该可视化。" #: ../../en/user-guide/observables.md:29 -msgid "Product screenshot pending · IMG-07" -msgstr "产品截图待补 · IMG-07" - -#: ../../en/user-guide/observables.md:32 -msgid "" -"**Purpose:** Show one measurement definition and its downstream result " -"together." -msgstr "**目的:** 同时展示一个测量定义及其下游结果。" - -#: ../../en/user-guide/observables.md -msgid "Capture specification" -msgstr "截图说明" - -#: ../../en/user-guide/observables.md:38 msgid "" -"**Capture:** Observable edge into the Trainer with its paired visualization " -"showing recorded values." -msgstr "**截图:** 展示连接到 Trainer 的 Observable 边,以及显示已记录数值的配套可视化。" +"Observable connected to a training graph with its recorded values shown " +"in a visualization." +msgstr "连接到训练图的 Observable,其记录值以可视化形式展示。" -#: ../../en/user-guide/observables.md:41 -msgid "**Final file:** `docs/en/_images/app/observable-and-visualization.png`" -msgstr "**最终文件:** `docs/en/_images/app/observable-and-visualization.png`" - -#: ../../en/user-guide/observables.md:45 +#: ../../en/user-guide/observables.md:34 msgid "Control measurement cost" msgstr "控制测量成本" -#: ../../en/user-guide/observables.md:47 +#: ../../en/user-guide/observables.md:36 msgid "" "Observables do not change the primary loss merely because they are " -"connected, but their measurement cost is real. Accuracy and scalar norms may" -" be cheap; Hessian, spectral, representation, or attention measurements can " -"dominate a small training run." +"connected, but their measurement cost is real. Accuracy and scalar norms " +"may be cheap; Hessian, spectral, representation, or attention " +"measurements can dominate a small training run." msgstr "" "Observables " "不会仅因被连接就改变主损失,但其测量成本真实存在。准确率和标量范数可能很便宜;Hessian、谱、表征或注意力测量可能主导一次小型训练运行。" -#: ../../en/user-guide/observables.md:52 +#: ../../en/user-guide/observables.md:41 msgid "Use log frequency as part of the measurement design:" msgstr "将记录频率视为测量设计的一部分:" -#: ../../en/user-guide/observables.md:54 +#: ../../en/user-guide/observables.md:43 msgid "log frequently enough to resolve the phenomenon of interest;" msgstr "足够频繁地记录,以分辨感兴趣的现象;" -#: ../../en/user-guide/observables.md:55 -msgid "log less often for expensive second-order or high-dimensional measurements;" +#: ../../en/user-guide/observables.md:44 +msgid "" +"log less often for expensive second-order or high-dimensional " +"measurements;" msgstr "对昂贵的二阶或高维测量降低记录频率;" -#: ../../en/user-guide/observables.md:56 +#: ../../en/user-guide/observables.md:45 msgid "reduce model or sample size when validating a new Observable;" msgstr "验证新 Observable 时减小模型或样本规模;" -#: ../../en/user-guide/observables.md:57 +#: ../../en/user-guide/observables.md:46 msgid "temporarily disable extra Observables when isolating a training failure." msgstr "隔离训练故障时暂时禁用额外的 Observables。" -#: ../../en/user-guide/observables.md:59 +#: ../../en/user-guide/observables.md:48 msgid "" -"Changing the logging schedule changes the sampled signal. Keep it fixed when" -" comparing curves and record it with the graph." +"Changing the logging schedule changes the sampled signal. Keep it fixed " +"when comparing curves and record it with the graph." msgstr "改变记录计划会改变采样信号。比较曲线时请保持它固定,并随图一并记录。" -#: ../../en/user-guide/observables.md:62 +#: ../../en/user-guide/observables.md:51 msgid "Decide what supports the claim" msgstr "确定支持结论的证据" -#: ../../en/user-guide/observables.md:64 +#: ../../en/user-guide/observables.md:53 msgid "" -"Attach an Observable because it can distinguish the hypotheses being tested," -" not because it creates an interesting chart. For every measurement, be able" -" to state:" +"Attach an Observable because it can distinguish the hypotheses being " +"tested, not because it creates an interesting chart. For every " +"measurement, be able to state:" msgstr "添加 Observable 是因为它能区分正在检验的假设,而不是因为它能生成有趣的图表。对每项测量,都应能说明:" -#: ../../en/user-guide/observables.md:68 +#: ../../en/user-guide/observables.md:57 msgid "which training state it reads;" msgstr "它读取哪种训练状态;" -#: ../../en/user-guide/observables.md:69 +#: ../../en/user-guide/observables.md:58 msgid "how it reduces or samples that state;" msgstr "它如何归约或采样该状态;" -#: ../../en/user-guide/observables.md:70 +#: ../../en/user-guide/observables.md:59 msgid "when it is recorded;" msgstr "何时记录;" -#: ../../en/user-guide/observables.md:71 +#: ../../en/user-guide/observables.md:60 msgid "what comparison would support or contradict the claim;" msgstr "什么比较会支持或反驳该结论;" -#: ../../en/user-guide/observables.md:72 +#: ../../en/user-guide/observables.md:61 msgid "what approximation or truncation limits its interpretation." msgstr "哪些近似或截断限制了其解释。" -#: ../../en/user-guide/observables.md:74 +#: ../../en/user-guide/observables.md:63 msgid "" "Save the settings with the graph, then use [Make a result " "reproducible](reproducibility.md) before reporting it." msgstr "将设置随图保存,并在报告前参考[让结果可复现](reproducibility.md)。" + diff --git a/docs/locales/zh_CN/LC_MESSAGES/user-guide/projects-and-artifacts.po b/docs/locales/zh_CN/LC_MESSAGES/user-guide/projects-and-artifacts.po index 5fb87cd..7f823c9 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/user-guide/projects-and-artifacts.po +++ b/docs/locales/zh_CN/LC_MESSAGES/user-guide/projects-and-artifacts.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-27 19:58+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: zh_CN\n" @@ -52,124 +52,96 @@ msgid "" msgstr "已保存的 Template 会在新项目中以工作副本打开。库条目仍与之后在画布上的编辑分离。" #: ../../en/user-guide/projects-and-artifacts.md:27 -msgid "Product screenshot pending · IMG-08" -msgstr "产品截图待补 · IMG-08" - -#: ../../en/user-guide/projects-and-artifacts.md:30 -msgid "**Purpose:** Make ownership between a project and its canvas clear." -msgstr "**目的:** 明确项目与其画布之间的归属关系。" - -#: ../../en/user-guide/projects-and-artifacts.md -msgid "Capture specification" -msgstr "截图说明" - -#: ../../en/user-guide/projects-and-artifacts.md:36 msgid "" -"**Capture:** The project tab bar with a Baseline and a Comparison " -"project, one open in the workbench." -msgstr "**截图:** 展示项目标签栏,其中包含 Baseline 和 Comparison 两个项目,并在工作台中打开其中一个。" - -#: ../../en/user-guide/projects-and-artifacts.md:39 -msgid "**Final file:** `docs/en/_images/app/project-canvas-tree.png`" -msgstr "**最终文件:** `docs/en/_images/app/project-canvas-tree.png`" +"Baseline and Comparison project tabs with the Baseline project's single " +"canvas open in the workbench." +msgstr "Baseline 和 Comparison 项目标签,工作台中打开的是 Baseline 项目的单一画布。" -#: ../../en/user-guide/projects-and-artifacts.md:43 +#: ../../en/user-guide/projects-and-artifacts.md:32 msgid "Choose a destination" msgstr "选择保存位置" -#: ../../en/user-guide/projects-and-artifacts.md:45 +#: ../../en/user-guide/projects-and-artifacts.md:34 msgid "Use the Graph menu to save a graph file or Template:" msgstr "使用 Graph 菜单保存图文件或 Template:" -#: ../../en/user-guide/projects-and-artifacts.md:47 +#: ../../en/user-guide/projects-and-artifacts.md:36 msgid "a **Template** is a reusable starting point." msgstr "**Template** 是可复用的起点。" -#: ../../en/user-guide/projects-and-artifacts.md:49 +#: ../../en/user-guide/projects-and-artifacts.md:38 msgid "Choose a size tier" msgstr "选择大小层级" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Tier" msgstr "层级" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Retains" msgstr "保留" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Removes" msgstr "移除" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Recommended use" msgstr "推荐用途" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Small" msgstr "小" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Graph structure and node settings" msgstr "图结构和节点设置" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Plot histories and checkpoint bytes" msgstr "绘图历史和检查点字节" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Reviewable templates and graph files" msgstr "可审阅的 template 和图文件" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Medium" msgstr "中" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Graph and plot or visualization data" msgstr "图以及绘图或可视化数据" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Checkpoint bytes" msgstr "检查点字节" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Sharing completed curves without a model blob" msgstr "在不含模型 blob 的情况下共享已完成曲线" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Large" msgstr "大" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Full graph, plots, and model checkpoint data" msgstr "完整图、绘图和模型检查点数据" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Nothing" msgstr "无" -#: ../../en/user-guide/projects-and-artifacts.md:32 +#: ../../en/user-guide/projects-and-artifacts.md:27 msgid "Local archival when trained state is required" msgstr "需要已训练状态时的本地归档" -#: ../../en/user-guide/projects-and-artifacts.md:57 -msgid "Product screenshot pending · IMG-09" -msgstr "产品截图待补 · IMG-09" - -#: ../../en/user-guide/projects-and-artifacts.md:60 -msgid "**Purpose:** Show the actual tier decision where persistence cost changes." -msgstr "**目的:** 展示持久化成本发生变化时实际进行层级选择的位置。" - -#: ../../en/user-guide/projects-and-artifacts.md:66 -msgid "**Capture:** Graph save menu with tier names and descriptions visible." -msgstr "**截图:** 展示图保存菜单,其中层级名称和说明均清晰可见。" +#: ../../en/user-guide/projects-and-artifacts.md:46 +msgid "Save dialog showing the available artifact persistence tiers." +msgstr "保存对话框,显示可用的产物持久化分级。" -#: ../../en/user-guide/projects-and-artifacts.md:68 -msgid "**Final file:** `docs/en/_images/app/save-artifact-tiers.png`" -msgstr "**最终文件:** `docs/en/_images/app/save-artifact-tiers.png`" - -#: ../../en/user-guide/projects-and-artifacts.md:72 +#: ../../en/user-guide/projects-and-artifacts.md:51 msgid "" "Prefer Small for committed Templates. Medium and Large JSON can contain " "experiment results or encoded model state, grow rapidly, and expose " @@ -179,11 +151,11 @@ msgstr "" "对于提交的 Template,优先使用 Small。Medium 和 Large JSON " "可能包含实验结果或编码的模型状态,体积增长很快,并暴露不应进入 Git 历史的信息。共享前请检查已保存文件。" -#: ../../en/user-guide/projects-and-artifacts.md:77 +#: ../../en/user-guide/projects-and-artifacts.md:56 msgid "Know what is local state" msgstr "了解哪些内容属于本地状态" -#: ../../en/user-guide/projects-and-artifacts.md:79 +#: ../../en/user-guide/projects-and-artifacts.md:58 msgid "" "The active workspace is persisted through `/api/workspace` to " "`data/workspace.json`. Graph-library data is stored under " @@ -194,7 +166,7 @@ msgstr "" "`data/graph_library/` 下;提交的规范 template 位于 " "`data/graph_library/templates/`。" -#: ../../en/user-guide/projects-and-artifacts.md:84 +#: ../../en/user-guide/projects-and-artifacts.md:63 msgid "" "The repository ignores most local workspace, library, credential, and " "runtime artifact state, but ignore rules are not a data-handling policy. " @@ -202,8 +174,9 @@ msgid "" "archive." msgstr "仓库会忽略大多数本地工作区、库、凭据和运行时产物状态,但忽略规则不是数据处理策略。提交或发送归档前,请检查实际文件和 Git 状态。" -#: ../../en/user-guide/projects-and-artifacts.md:88 +#: ../../en/user-guide/projects-and-artifacts.md:67 msgid "" "Use [Make a result reproducible](reproducibility.md) to decide what " "context a shared artifact still needs outside its JSON." msgstr "使用[使结果可复现](reproducibility.md)来决定共享产物在其 JSON 之外仍需要哪些上下文。" + diff --git a/docs/locales/zh_CN/LC_MESSAGES/user-guide/remote-gpu.po b/docs/locales/zh_CN/LC_MESSAGES/user-guide/remote-gpu.po index 32c74b8..d32fb08 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/user-guide/remote-gpu.po +++ b/docs/locales/zh_CN/LC_MESSAGES/user-guide/remote-gpu.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Comfy Research \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-24 14:05+0800\n" +"POT-Creation-Date: 2026-08-15 20:07+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: zh_CN\n" @@ -29,8 +29,8 @@ msgstr "使用远程 GPU" #: ../../en/user-guide/remote-gpu.md:13 msgid "" -"Keep the application local, send a validated training graph to an existing " -"SSH host, and stream progress back to the same Trainer." +"Keep the application local, send a validated training graph to an " +"existing SSH host, and stream progress back to the same Trainer." msgstr "保持应用在本地,将经过验证的训练图发送到现有 SSH 主机,并将进度流式返回同一个 Trainer。" #: ../../en/user-guide/remote-gpu.md:17 @@ -39,9 +39,9 @@ msgstr "配置远程执行前" #: ../../en/user-guide/remote-gpu.md:19 msgid "" -"First complete a CPU run with the local service bound to `127.0.0.1`. Remote" -" execution adds SSH, environment, dependency, and data-transfer failure " -"modes; it should not be the first test of graph correctness." +"First complete a CPU run with the local service bound to `127.0.0.1`. " +"Remote execution adds SSH, environment, dependency, and data-transfer " +"failure modes; it should not be the first test of graph correctness." msgstr "" "请先在绑定到 `127.0.0.1` 的本地服务上完成一次 CPU 运行。远程执行增加了 " "SSH、环境、依赖和数据传输故障模式;它不应成为图正确性的首次测试。" @@ -66,112 +66,96 @@ msgstr "输入 SSH 主机和用户、远程仓库路径以及远程 Python。" #: ../../en/user-guide/remote-gpu.md:30 msgid "" -"Prefer an SSH identity file. Leave the password field empty when key-based " -"authentication is available." +"Prefer an SSH identity file. Leave the password field empty when key-" +"based authentication is available." msgstr "优先使用 SSH 身份文件。可使用密钥认证时请保持密码字段为空。" #: ../../en/user-guide/remote-gpu.md:32 msgid "" -"Enable dataset upload only when the graph needs local data that is absent on" -" the remote host." +"Enable dataset upload only when the graph needs local data that is absent" +" on the remote host." msgstr "仅当图需要远程主机上没有的本地数据时才启用数据集上传。" #: ../../en/user-guide/remote-gpu.md:34 msgid "" -"Run the Trainer. The first request bootstraps the remote runtime, validates " -"the graph, and then starts streamed training." +"Run the Trainer. The first request bootstraps the remote runtime, " +"validates the graph, and then starts streamed training." msgstr "运行 Trainer。首个请求会引导远程运行时、验证图,然后开始流式训练。" #: ../../en/user-guide/remote-gpu.md:37 -msgid "Product screenshot pending · IMG-10" -msgstr "产品截图待补 · IMG-10" - -#: ../../en/user-guide/remote-gpu.md:40 -msgid "" -"**Purpose:** Show which remote connection fields belong together without " -"exposing credentials." -msgstr "**目的:** 展示哪些远程连接字段属于同一组,同时不暴露凭据。" - -#: ../../en/user-guide/remote-gpu.md -msgid "Capture specification" -msgstr "截图说明" - -#: ../../en/user-guide/remote-gpu.md:46 msgid "" -"**Capture:** Use `gpu.example.invalid`, user `researcher`, a fake remote " -"path, and an empty password field." -msgstr "**截图:** 使用 `gpu.example.invalid`、用户 `researcher`、虚构远程路径和空密码字段。" +"Remote GPU configuration using a fake host, SSH identity file, remote " +"path, and Python command." +msgstr "远程 GPU 配置,使用示例主机、SSH 身份文件、远程路径和 Python 命令。" -#: ../../en/user-guide/remote-gpu.md:49 -msgid "**Final file:** `docs/en/_images/app/remote-gpu-configuration.png`" -msgstr "**最终文件:** `docs/en/_images/app/remote-gpu-configuration.png`" - -#: ../../en/user-guide/remote-gpu.md:53 +#: ../../en/user-guide/remote-gpu.md:42 msgid "Protect credentials" msgstr "保护凭据" -#: ../../en/user-guide/remote-gpu.md:55 +#: ../../en/user-guide/remote-gpu.md:44 msgid "" "Trainer remote settings are saved under " -"`.comfyresearch/remote_train_config.json`. This file is Git-ignored, but any" -" entered password is stored as plain JSON on the local machine. Git ignore " -"does not encrypt it or protect copies made by backups and support bundles." +"`.comfyresearch/remote_train_config.json`. This file is Git-ignored, but " +"any entered password is stored as plain JSON on the local machine. Git " +"ignore does not encrypt it or protect copies made by backups and support " +"bundles." msgstr "" "Trainer 远程设置保存在 `.comfyresearch/remote_train_config.json` 下。该文件被 Git " "忽略,但任何输入的密码都会以明文 JSON 保存在本机。Git 忽略不会加密它,也不会保护备份和支持包中的副本。" -#: ../../en/user-guide/remote-gpu.md:60 +#: ../../en/user-guide/remote-gpu.md:49 msgid "Prefer, in order:" msgstr "按以下顺序优先选择:" -#: ../../en/user-guide/remote-gpu.md:62 +#: ../../en/user-guide/remote-gpu.md:51 msgid "SSH key authentication with a restricted identity file;" msgstr "使用受限身份文件的 SSH 密钥认证;" -#: ../../en/user-guide/remote-gpu.md:63 +#: ../../en/user-guide/remote-gpu.md:52 msgid "environment variables supplied by a local secret manager;" msgstr "由本地密钥管理器提供的环境变量;" -#: ../../en/user-guide/remote-gpu.md:64 +#: ../../en/user-guide/remote-gpu.md:53 msgid "" -"a saved password only on a controlled workstation when the other methods are" -" unavailable." +"a saved password only on a controlled workstation when the other methods " +"are unavailable." msgstr "仅在其他方法不可用时,于受控工作站保存密码。" -#: ../../en/user-guide/remote-gpu.md:67 +#: ../../en/user-guide/remote-gpu.md:56 msgid "" "Never place real credentials in a graph, screenshot, issue, committed " -"config, or documentation example. Remove the saved config before sharing a " -"repository archive." +"config, or documentation example. Remove the saved config before sharing " +"a repository archive." msgstr "绝不要将真实凭据放入图、截图、Issue、已提交配置或文档示例中。共享仓库归档前请移除已保存的配置。" -#: ../../en/user-guide/remote-gpu.md:71 +#: ../../en/user-guide/remote-gpu.md:60 msgid "Verify the run boundary" msgstr "验证运行边界" -#: ../../en/user-guide/remote-gpu.md:73 +#: ../../en/user-guide/remote-gpu.md:62 msgid "" "At the start of remote training, the backend synchronizes the required " -"source bundle when its digest changed, checks dependencies, validates the " -"graph, and streams NDJSON events over SSH. Pause and abort are cooperative " -"remote control requests; loss of SSH can interrupt control even if the " -"remote process still exists." +"source bundle when its digest changed, checks dependencies, validates the" +" graph, and streams NDJSON events over SSH. Pause and abort are " +"cooperative remote control requests; loss of SSH can interrupt control " +"even if the remote process still exists." msgstr "" "远程训练开始时,后端会在摘要发生变化时同步所需源代码包,检查依赖、验证图,并通过 SSH 流式传输 NDJSON " "事件。暂停和中止是协作式远程控制请求;即使远程进程仍在,SSH 断开也可能中断控制。" -#: ../../en/user-guide/remote-gpu.md:79 +#: ../../en/user-guide/remote-gpu.md:68 msgid "" -"Treat the result like any other reproduction: record the remote Python and " -"package environment, accelerator type, graph artifact, seeds, and dataset " -"provenance. A successful remote stream proves transport and execution, not " -"equivalence with a local numeric result." +"Treat the result like any other reproduction: record the remote Python " +"and package environment, accelerator type, graph artifact, seeds, and " +"dataset provenance. A successful remote stream proves transport and " +"execution, not equivalence with a local numeric result." msgstr "" "将该结果视为任何其他复现:记录远程 Python " "和包环境、加速器类型、图产物、种子和数据集来源。成功的远程流只能证明传输和执行成功,不能证明与本地数值结果等价。" -#: ../../en/user-guide/remote-gpu.md:84 +#: ../../en/user-guide/remote-gpu.md:73 msgid "" "See [Application reference](../reference/application.md) for the remote " "environment variables and precedence rules." msgstr "远程环境变量和优先级规则请参阅[应用参考](../reference/application.md)。" + diff --git a/docs/superpowers/plans/2026-08-15-run-store.md b/docs/superpowers/plans/2026-08-15-run-store.md new file mode 100644 index 0000000..e0c936c --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-run-store.md @@ -0,0 +1,2657 @@ +# Run Store Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist every training run server-side (files as truth + rebuildable SQLite index) with an agent-first async submit/query API and a canvas Runs panel. + +**Architecture:** A `RunWriter` captures trainer NDJSON events at the engine layer (delta-extracting cumulative histories into `data/runs/{run_id}/metrics.ndjson`, terminal snapshot into `results.json`, metadata in `run.json`), mirrored into a rebuildable SQLite index (`data/runs/index.db`, WAL). A worker pool executes async-submitted runs detached from any HTTP stream. Spec: `docs/superpowers/specs/2026-08-15-run-store-design.md`. + +**Tech Stack:** Python 3.10+, FastAPI, Pydantic v2, stdlib `sqlite3`; React + TypeScript + Vitest frontend (hand-rolled SVG charts, plain fetch). + +## Global Constraints + +- Files are the source of truth; the SQLite index must be rebuildable from `data/runs/*/run.json` at any time. Index write failure logs a warning, never fails a run. +- All JSON writes atomic: `.tmp` + `Path.replace` (mirror `graph_library.py:_atomic_write_json`). +- Never persist `checkpoint_b64`, `memoryCheckpoint_b64`, `plot_png_base64`, `observable_embedding_histories`, `observable_attention_slice_histories` in the run store. +- All persisted event data passes through `sanitize_train_ndjson_value` (NaN/Inf → None). +- SQLite: WAL mode, `timeout=5` connections, single-upsert transactions; index writes only at registration / coalesced heartbeat (≥1 s apart) / terminal state. +- CRL runs (`crl_trainer` node type) are NOT captured in v1; classic trainer runs only. +- Statuses: `queued | running | completed | failed | aborted | paused | crashed | unreadable`. Terminal = all except `queued`/`running`. +- New API error responses use `{"code": "", "detail": ""}` via HTTPException detail dict. +- Frontend: no new chart libs; new CSS uses `var(--cr-*)` tokens only (a raw hex fails `npm run verify:css-tokens`); backend URLs are relative `/api/...`. +- Backend tests: pytest, files in `comfy_research/tests/`; run store tests must isolate via the `COMFYRESEARCH_RUNS_DIR` env var + `tmp_path`. Frontend tests: Vitest in `frontend/src/graph/__tests__/`, `// @vitest-environment jsdom` where DOM is needed. +- Run backend tests with `python -m pytest -v` from the repo root, INSIDE the project's environment (torch and requirements.txt installed — a bare interpreter fails on `import torch`). Verify with `python -c "import torch"` before starting; if it fails, ask the user which environment to use rather than pip-installing torch. + +## File Structure + +| File | Responsibility | +| --- | --- | +| `comfy_research/schemas/run_record.py` (new) | `RunRecord` model, run-id minting, result-key stripping, hyperparam flattening | +| `comfy_research/engine/runs/run_store.py` (new) | Paths, atomic file persistence, `MetricsDeltaTracker`, results/metrics read-back | +| `comfy_research/engine/runs/run_index.py` (new) | SQLite index: schema, upsert, query, rebuild, stale-run reconciliation | +| `comfy_research/engine/runs/run_writer.py` (new) | `RunWriter` facade (files + index + heartbeat + finalization), `capture_events` | +| `comfy_research/engine/runs/run_worker.py` (new) | Async-submit worker pool, per-trainer serialization, abort, idempotency | +| `comfy_research/engine/runs/run_gc.py` (new) | Retention config + single-owner GC | +| `comfy_research/schemas/train_request.py` (modify) | Optional `run_origin` / `run_group_id` / `run_parent_id` | +| `comfy_research/api/train.py` (modify) | Tee local + remote stream generators through `capture_events` | +| `comfy_research/engine/runs/train_sweep.py` (modify) | Per-inner-run capture with `group_id` = sweep session | +| `comfy_research/api/runs.py` (new) | `/api/runs` router | +| `comfy_research/main.py` (modify) | Register router; startup reconciliation + GC | +| `frontend/src/graph/runsApi.ts` (new) | Typed fetch client for `/api/runs` | +| `frontend/src/components/RunsPanel.tsx` (new) | Rail panel: grouped run list, selection, overlay chart | +| `frontend/src/components/railTypes.ts`, `LeftNavRail.tsx`, `ResearchCanvas.tsx` (modify) | Rail wiring + open-run-graph action | +| `docs/en/reference/runs-api.md` (new), `docs/en/reference/data-contracts.md` (modify) | API + on-disk contract docs | + +--- + +### Task 1: RunRecord schema, stripping, hyperparam flattening + +**Files:** +- Create: `comfy_research/schemas/run_record.py` +- Test: `comfy_research/tests/test_run_record.py` + +**Interfaces:** +- Consumes: `GraphDocument`, `Node` from `comfy_research.schemas.graph`; `load_node_manifest` from `comfy_research.generated.node_manifest`. +- Produces: `RunRecord` (pydantic, fields below), `new_run_id() -> str`, `strip_result_data(nodes: list[Node]) -> list[Node]`, `flatten_hyperparams(nodes: list[Node]) -> dict[str, float | int | str | bool]`, `RUN_RESULT_DATA_KEYS: frozenset[str]`, `TERMINAL_STATUSES: frozenset[str]`, `now_ms() -> float`. + +- [ ] **Step 1: Write the failing test** + +```python +# comfy_research/tests/test_run_record.py +from __future__ import annotations + +from comfy_research.schemas.graph import Edge, GraphDocument, Node +from comfy_research.schemas.run_record import ( + RUN_RESULT_DATA_KEYS, + RunRecord, + flatten_hyperparams, + new_run_id, + now_ms, + strip_result_data, +) + + +def _trainer_node() -> Node: + return Node( + id="t1", + type="trainer", + data={ + "trainingSteps": 4, + "computeDevice": "cpu", + "instanceTitle": "Trainer", + "lossHistory": [1.0, 0.5], + "memoryCheckpoint_b64": "QUJD", + "plotPngBase64": "aW1n", + }, + ) + + +def test_new_run_id_prefix_and_uniqueness() -> None: + a, b = new_run_id(), new_run_id() + assert a.startswith("run-") and len(a) == 16 + assert a != b + + +def test_strip_result_data_removes_blobs_keeps_config() -> None: + stripped = strip_result_data([_trainer_node()]) + data = stripped[0].data + assert data["trainingSteps"] == 4 + assert data["instanceTitle"] == "Trainer" + assert "lossHistory" not in data + assert "memoryCheckpoint_b64" not in data + assert "plotPngBase64" not in data + # original untouched + assert "lossHistory" in _trainer_node().data + + +def test_result_keys_cover_known_blobs() -> None: + for key in ("checkpoint_b64", "memoryCheckpoint_b64", "plotPngBase64", + "lossHistory", "testLossHistory", "regLossHistory", "stepTicks", + "observableMetricHistories", "embeddingHistory", + "attentionMapFrames", "valueHistory", "runSummary", "lastError"): + assert key in RUN_RESULT_DATA_KEYS + + +def test_flatten_hyperparams_declared_scalars_only() -> None: + flat = flatten_hyperparams([_trainer_node()]) + assert flat["t1.trainingSteps"] == 4 + assert flat["t1.computeDevice"] == "cpu" + assert "t1.lossHistory" not in flat # not a declared field + assert "t1.instanceTitle" not in flat # declared but excluded as label + + +def test_run_record_roundtrip() -> None: + rec = RunRecord( + run_id=new_run_id(), + origin="agent", + status="queued", + created_at=now_ms(), + trainer_node_id="t1", + graph=GraphDocument(version=1, nodes=strip_result_data([_trainer_node()]), edges=[]), + hyperparams=flatten_hyperparams([_trainer_node()]), + ) + again = RunRecord.model_validate(rec.model_dump(mode="json")) + assert again.run_id == rec.run_id + assert again.schema_version == 1 + assert again.group_id is None and again.finished_at is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest comfy_research/tests/test_run_record.py -v` +Expected: FAIL with `ModuleNotFoundError: comfy_research.schemas.run_record` + +- [ ] **Step 3: Write the implementation** + +```python +# comfy_research/schemas/run_record.py +"""RunRecord: persisted metadata for one training run (``data/runs/{run_id}/run.json``).""" +from __future__ import annotations + +import time +import uuid +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from comfy_research.generated.node_manifest import load_node_manifest +from comfy_research.schemas.graph import GraphDocument, Node + +RunStatus = Literal[ + "queued", "running", "completed", "failed", "aborted", "paused", "crashed", "unreadable" +] +TERMINAL_STATUSES: frozenset[str] = frozenset( + {"completed", "failed", "aborted", "paused", "crashed"} +) + +# Result/UI payloads the browser stashes into node data; never persisted in run configs. +# Superset mirror of frontend/src/graph/graphFileExportTier.ts strip lists. +RUN_RESULT_DATA_KEYS: frozenset[str] = frozenset( + { + "checkpoint_b64", "memoryCheckpoint_b64", + "lossHistory", "testLossHistory", "regLossHistory", + "stepTicks", "epochTicks", "observableMetricHistories", + "lastTrainLoopSeconds", "plotPngBase64", "valueHistory", + "embeddingHistory", "attentionMapFrames", "previewGrid", + "histogramPng", "imageGrid", "runSummary", "lastError", + "lastSweepSummary", "observableEmbeddingHistories", + "observableAttentionSliceHistories", + } +) + +_HYPERPARAM_EXCLUDED_FIELDS = frozenset({"instanceTitle"}) + + +class RunRecord(BaseModel): + run_id: str + schema_version: int = 1 + group_id: str | None = None + parent_id: str | None = None + origin: Literal["human", "agent", "sweep"] = "human" + status: RunStatus = "queued" + created_at: float + started_at: float | None = None + finished_at: float | None = None + trainer_node_id: str + device: str = "" + error_detail: str = "" + graph: GraphDocument + hyperparams: dict[str, Any] = Field(default_factory=dict) + + +def now_ms() -> float: + return time.time() * 1000.0 + + +def new_run_id() -> str: + return "run-" + uuid.uuid4().hex[:12] + + +def strip_result_data(nodes: list[Node]) -> list[Node]: + """Copy nodes with result/UI blobs removed from ``data`` (config-only snapshot).""" + out: list[Node] = [] + for n in nodes: + data = {k: v for k, v in (n.data or {}).items() if k not in RUN_RESULT_DATA_KEYS} + out.append(n.model_copy(update={"data": data}, deep=True)) + return out + + +def _declared_field_keys(node_type: str) -> list[str]: + for entry in load_node_manifest(): + if entry.get("type") == node_type: + return [f["key"] for f in entry.get("fields", [])] + return [] + + +def flatten_hyperparams(nodes: list[Node]) -> dict[str, Any]: + """``{node_id}.{field}`` -> scalar, for manifest-declared fields present in node data.""" + flat: dict[str, Any] = {} + for n in nodes: + data = n.data or {} + for key in _declared_field_keys(str(n.type)): + if key in _HYPERPARAM_EXCLUDED_FIELDS or key not in data: + continue + v = data[key] + if isinstance(v, (bool, int, float, str)): + flat[f"{n.id}.{key}"] = v + return flat +``` + +Before finalizing, check the actual shape returned by `load_node_manifest()` (`comfy_research/generated/node_manifest.py`) — if it returns an object keyed by type or a list under a `"nodes"` key, adapt `_declared_field_keys` accordingly (keep the returned-keys contract identical). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest comfy_research/tests/test_run_record.py -v` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add comfy_research/schemas/run_record.py comfy_research/tests/test_run_record.py +git commit -m "feat: add RunRecord schema with result stripping and hyperparam flattening" +``` + +--- + +### Task 2: File store + metrics delta tracker + +**Files:** +- Create: `comfy_research/engine/runs/run_store.py` +- Test: `comfy_research/tests/test_run_store.py` + +**Interfaces:** +- Consumes: `RunRecord`, `TERMINAL_STATUSES` (Task 1); `sanitize_train_ndjson_value`. +- Produces: `runs_root() -> Path` (env `COMFYRESEARCH_RUNS_DIR` override, else `/data/runs`), `run_dir(run_id) -> Path`, `write_run_record(rec) -> None` (atomic), `read_run_record(run_id) -> RunRecord | None`, `append_metric_rows(run_id, rows: list[dict]) -> None`, `read_metric_rows(run_id) -> list[dict]` (tolerates truncated tail), `write_results(run_id, payload: dict) -> None` (atomic, sanitized), `read_results(run_id) -> dict | None`, `load_series(run_id) -> tuple[str, dict]` (single implementation of the authority rule: `("results", ...)` when `results.json` exists, else `("ndjson", ...)` rebuilt from metric rows), `class MetricsDeltaTracker` with `extract(event: dict) -> list[dict]` and `snapshot() -> dict`, `SERIES_KEYS`, `summarize(snapshot: dict) -> dict` (final_loss, final_test_loss, best_test_loss, steps_completed). + +- [ ] **Step 1: Write the failing test** + +```python +# comfy_research/tests/test_run_store.py +from __future__ import annotations + +from comfy_research.schemas.graph import GraphDocument +from comfy_research.schemas.run_record import RunRecord, new_run_id, now_ms +from comfy_research.engine.runs import run_store + + +def _record(run_id: str) -> RunRecord: + return RunRecord( + run_id=run_id, origin="agent", status="running", created_at=now_ms(), + trainer_node_id="t1", graph=GraphDocument(version=1, nodes=[], edges=[]), + ) + + +def _metrics_event(n: int) -> dict: + return { + "type": "metrics", "step": n, + "loss_history": [1.0 / (i + 1) for i in range(n)], + "test_loss_history": [2.0 / (i + 1) for i in range(n)], + "reg_loss_history": [], + "step_ticks": list(range(n)), + "epoch_ticks": [], + "observable_metric_histories": {"obs1:acc": [float(i) for i in range(n)]}, + "observable_warnings": {}, + } + + +def test_runs_root_env_override(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path / "r")) + assert run_store.runs_root() == tmp_path / "r" + + +def test_record_roundtrip_and_atomicity(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + rid = new_run_id() + run_store.write_run_record(_record(rid)) + rec = run_store.read_run_record(rid) + assert rec is not None and rec.run_id == rid + assert not list((tmp_path / rid).glob("*.tmp")) + assert run_store.read_run_record("run-missing00000") is None + + +def test_delta_tracker_appends_only_new_rows() -> None: + tracker = run_store.MetricsDeltaTracker() + rows1 = tracker.extract(_metrics_event(2)) + rows2 = tracker.extract(_metrics_event(5)) + assert len(rows1) == 2 and len(rows2) == 3 + assert rows2[0]["idx"] == 2 and rows2[0]["step"] == 2 + assert rows2[-1]["loss"] == 1.0 / 5 + assert rows2[-1]["obs"]["obs1:acc"] == 4.0 + snap = tracker.snapshot() + assert len(snap["loss_history"]) == 5 + + +def test_delta_tracker_ignores_non_metrics_events() -> None: + tracker = run_store.MetricsDeltaTracker() + assert tracker.extract({"type": "progress", "step": 1, "total": 4}) == [] + + +def test_metrics_ndjson_roundtrip_tolerates_truncated_tail(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + rid = new_run_id() + run_store.write_run_record(_record(rid)) + run_store.append_metric_rows(rid, [{"idx": 0, "loss": 1.0}, {"idx": 1, "loss": 0.5}]) + path = tmp_path / rid / "metrics.ndjson" + path.write_text(path.read_text() + '{"idx": 2, "lo', encoding="utf-8") + rows = run_store.read_metric_rows(rid) + assert [r["idx"] for r in rows] == [0, 1] + + +def test_results_sanitized_and_stripped(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + rid = new_run_id() + run_store.write_run_record(_record(rid)) + run_store.write_results(rid, { + "loss_history": [1.0, float("nan")], + "checkpoint_b64": "QUJD", + "plot_png_base64": "aW1n", + "observable_embedding_histories": {"a": [1]}, + }) + res = run_store.read_results(rid) + assert res == {"loss_history": [1.0, None]} + + +def test_summarize() -> None: + tracker = run_store.MetricsDeltaTracker() + tracker.extract(_metrics_event(4)) + s = run_store.summarize(tracker.snapshot()) + assert s["final_loss"] == 0.25 + assert s["best_test_loss"] == 0.5 + assert s["steps_completed"] == 4 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest comfy_research/tests/test_run_store.py -v` +Expected: FAIL with `ImportError` / module not found + +- [ ] **Step 3: Write the implementation** + +```python +# comfy_research/engine/runs/run_store.py +"""File layer of the run store: ``data/runs/{run_id}/`` is the source of truth.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from comfy_research.schemas.run_record import RunRecord +from comfy_research.schemas.train_request import sanitize_train_ndjson_value + +_REPO_ROOT = Path(__file__).resolve().parents[3] + +SERIES_KEYS = ("loss_history", "test_loss_history", "reg_loss_history", + "step_ticks", "epoch_ticks") +_RESULT_STRIP_KEYS = frozenset( + {"checkpoint_b64", "plot_png_base64", "visualization_node_ids", + "observable_viz_updates", "observable_embedding_histories", + "observable_attention_slice_histories", "type"} +) + + +def runs_root() -> Path: + env = os.environ.get("COMFYRESEARCH_RUNS_DIR", "").strip() + return Path(env) if env else _REPO_ROOT / "data" / "runs" + + +def run_dir(run_id: str) -> Path: + if not run_id or "/" in run_id or "\\" in run_id or run_id.startswith("."): + raise ValueError(f"invalid run_id: {run_id!r}") + return runs_root() / run_id + + +def _atomic_write_json(path: Path, payload: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + tmp.replace(path) + + +def write_run_record(rec: RunRecord) -> None: + _atomic_write_json(run_dir(rec.run_id) / "run.json", rec.model_dump(mode="json")) + + +def read_run_record(run_id: str) -> RunRecord | None: + path = run_dir(run_id) / "run.json" + try: + return RunRecord.model_validate_json(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def append_metric_rows(run_id: str, rows: list[dict]) -> None: + if not rows: + return + path = run_dir(run_id) / "metrics.ndjson" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as f: + for row in rows: + f.write(json.dumps(sanitize_train_ndjson_value(row), separators=(",", ":")) + "\n") + + +def read_metric_rows(run_id: str) -> list[dict]: + path = run_dir(run_id) / "metrics.ndjson" + if not path.is_file(): + return [] + rows: list[dict] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + break # truncated tail from an interrupted append; drop it + return rows + + +def write_results(run_id: str, payload: dict) -> None: + kept = {k: v for k, v in payload.items() if k not in _RESULT_STRIP_KEYS} + _atomic_write_json(run_dir(run_id) / "results.json", + sanitize_train_ndjson_value(kept)) + + +def read_results(run_id: str) -> dict | None: + path = run_dir(run_id) / "results.json" + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +def load_series(run_id: str) -> tuple[str, dict]: + """Authority rule: ``results.json`` if present, else series rebuilt from ``metrics.ndjson``. + + Returns ("results" | "ndjson", series-dict). The single place that decides which + source wins — the API, index rebuild, and reconciliation must all use it. + """ + results = read_results(run_id) + if results is not None: + return "results", results + rows = read_metric_rows(run_id) + return "ndjson", { + "loss_history": [r.get("loss") for r in rows], + "test_loss_history": [r["test_loss"] for r in rows if "test_loss" in r], + "reg_loss_history": [r["reg_loss"] for r in rows if "reg_loss" in r], + "step_ticks": [r["step"] for r in rows if "step" in r], + "epoch_ticks": [r["epoch"] for r in rows if "epoch" in r], + } + + +class MetricsDeltaTracker: + """Turn cumulative-history ``metrics`` events into append-only delta rows. + + The trainer re-sends full histories on every emission; appending raw payloads + would be O(n^2) in storage. Track last-seen length, emit only new indices, + keep the latest cumulative snapshot for terminal fallback. + """ + + def __init__(self) -> None: + self._latest: dict[str, Any] = {k: [] for k in SERIES_KEYS} + self._latest["observable_metric_histories"] = {} + self._seen = 0 + + def extract(self, event: dict) -> list[dict]: + if event.get("type") != "metrics": + return [] + for key in SERIES_KEYS: + v = event.get(key) + if isinstance(v, list): + self._latest[key] = list(v) + obs = event.get("observable_metric_histories") + if isinstance(obs, dict): + self._latest["observable_metric_histories"] = { + str(k): list(v) for k, v in obs.items() if isinstance(v, list) + } + loss = self._latest["loss_history"] + rows: list[dict] = [] + for i in range(self._seen, len(loss)): + row: dict[str, Any] = {"idx": i, "loss": loss[i]} + for name, key in (("step", "step_ticks"), ("test_loss", "test_loss_history"), + ("reg_loss", "reg_loss_history"), ("epoch", "epoch_ticks")): + series = self._latest[key] + if i < len(series): + row[name] = series[i] + obs_row = { + k: v[i] + for k, v in self._latest["observable_metric_histories"].items() + if i < len(v) + } + if obs_row: + row["obs"] = obs_row + rows.append(row) + self._seen = len(loss) + return rows + + def snapshot(self) -> dict: + return {k: list(v) if isinstance(v, list) else dict(v) for k, v in self._latest.items()} + + +def summarize(snapshot: dict) -> dict: + loss = snapshot.get("loss_history") or [] + test = [x for x in (snapshot.get("test_loss_history") or []) if isinstance(x, (int, float))] + return { + "final_loss": loss[-1] if loss else None, + "final_test_loss": test[-1] if test else None, + "best_test_loss": min(test) if test else None, + "steps_completed": len(loss), + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest comfy_research/tests/test_run_store.py -v` +Expected: PASS (7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add comfy_research/engine/runs/run_store.py comfy_research/tests/test_run_store.py +git commit -m "feat: add run store file layer with metrics delta tracker" +``` + +--- + +### Task 3: SQLite index — upsert, query, rebuild, reconcile + +**Files:** +- Create: `comfy_research/engine/runs/run_index.py` +- Test: `comfy_research/tests/test_run_index.py` + +**Interfaces:** +- Consumes: `run_store.runs_root/read_run_record/write_run_record`, `RunRecord`, `TERMINAL_STATUSES`, `now_ms`. +- Produces: `index_path() -> Path`, `upsert_run(rec: RunRecord, summary: dict | None = None) -> None`, `touch_heartbeat(run_id: str, at_ms: float) -> None`, `class RunQuery` (pydantic-free dataclass: `status`, `origin`, `group_id`, `since_ms`, `ids`, `hyperparams: dict[str, str]`, `order_by="-created_at"`, `limit=100`, `cursor=None`), `query_runs(q: RunQuery) -> tuple[list[dict], str | None]` (rows + next cursor), `group_summary() -> list[dict]`, `delete_rows(run_ids: list[str]) -> None`, `rebuild_index() -> int`, `reconcile_stale_running(timeout_ms: float = 60_000) -> list[str]`. +- Row dict keys: `run_id, group_id, parent_id, origin, status, created_at, started_at, finished_at, last_heartbeat_at, trainer_node_id, device, error_detail, hyperparams (dict), final_loss, final_test_loss, best_test_loss, steps_completed, duration_seconds`. + +- [ ] **Step 1: Write the failing test** + +```python +# comfy_research/tests/test_run_index.py +from __future__ import annotations + +from comfy_research.schemas.graph import GraphDocument +from comfy_research.schemas.run_record import RunRecord, new_run_id, now_ms +from comfy_research.engine.runs import run_index, run_store +from comfy_research.engine.runs.run_index import RunQuery + + +def _record(status: str = "running", origin: str = "agent", + group: str | None = None, lr: float = 0.01) -> RunRecord: + return RunRecord( + run_id=new_run_id(), origin=origin, status=status, created_at=now_ms(), + group_id=group, trainer_node_id="t1", + graph=GraphDocument(version=1, nodes=[], edges=[]), + hyperparams={"opt1.lr": lr}, + ) + + +def _seed(monkeypatch, tmp_path): + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + + +def test_upsert_and_query_filters(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + a = _record(status="completed", lr=0.01) + b = _record(status="failed", lr=0.1) + for r in (a, b): + run_store.write_run_record(r) + run_index.upsert_run(r) + rows, cursor = run_index.query_runs(RunQuery(status="completed")) + assert [r["run_id"] for r in rows] == [a.run_id] + assert cursor is None + rows, _ = run_index.query_runs(RunQuery(hyperparams={"opt1.lr": "0.1"})) + assert [r["run_id"] for r in rows] == [b.run_id] + rows, _ = run_index.query_runs(RunQuery(ids=[a.run_id])) + assert rows[0]["hyperparams"] == {"opt1.lr": 0.01} + + +def test_cursor_pagination(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + made = [] + for _ in range(5): + r = _record(status="completed") + run_store.write_run_record(r) + run_index.upsert_run(r) + made.append(r.run_id) + page1, cur1 = run_index.query_runs(RunQuery(limit=2)) + page2, cur2 = run_index.query_runs(RunQuery(limit=2, cursor=cur1)) + page3, cur3 = run_index.query_runs(RunQuery(limit=2, cursor=cur2)) + ids = [r["run_id"] for r in page1 + page2 + page3] + assert sorted(ids) == sorted(made) and len(ids) == 5 + assert cur3 is None + + +def test_summary_columns_and_order_by(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + a, b = _record(status="completed"), _record(status="completed") + for r, loss in ((a, 0.5), (b, 0.1)): + run_store.write_run_record(r) + run_index.upsert_run(r, summary={"final_loss": loss, "final_test_loss": None, + "best_test_loss": None, "steps_completed": 3}) + rows, _ = run_index.query_runs(RunQuery(order_by="final_loss")) + assert [r["run_id"] for r in rows] == [b.run_id, a.run_id] + assert rows[0]["final_loss"] == 0.1 and rows[0]["steps_completed"] == 3 + + +def test_rebuild_surfaces_unreadable_run(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + good = _record(status="completed") + run_store.write_run_record(good) + run_index.upsert_run(good) + bad_dir = tmp_path / "run-corrupted0001" + bad_dir.mkdir() + (bad_dir / "run.json").write_text("{not json", encoding="utf-8") + assert run_index.rebuild_index() == 2 + rows, _ = run_index.query_runs(RunQuery(status="unreadable")) + assert [r["run_id"] for r in rows] == ["run-corrupted0001"] + + +def test_rebuild_from_files(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + recs = [_record(status="completed") for _ in range(3)] + for r in recs: + run_store.write_run_record(r) + run_index.upsert_run(r) + before, _ = run_index.query_runs(RunQuery()) + run_index.index_path().unlink() + assert run_index.rebuild_index() == 3 + after, _ = run_index.query_runs(RunQuery()) + assert {r["run_id"] for r in after} == {r["run_id"] for r in before} + + +def test_reconcile_stale_running(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + stale = _record(status="running") + run_store.write_run_record(stale) + run_index.upsert_run(stale) + run_index.touch_heartbeat(stale.run_id, now_ms() - 120_000) + fresh = _record(status="running") + run_store.write_run_record(fresh) + run_index.upsert_run(fresh) + run_index.touch_heartbeat(fresh.run_id, now_ms()) + + crashed = run_index.reconcile_stale_running(timeout_ms=60_000) + assert crashed == [stale.run_id] + assert run_store.read_run_record(stale.run_id).status == "crashed" + rows, _ = run_index.query_runs(RunQuery(ids=[fresh.run_id])) + assert rows[0]["status"] == "running" + + +def test_group_summary_and_delete(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + a = _record(status="completed", group="g1") + b = _record(status="failed", group="g1") + for r in (a, b): + run_store.write_run_record(r) + run_index.upsert_run(r) + groups = run_index.group_summary() + g1 = next(g for g in groups if g["group_id"] == "g1") + assert g1["counts"] == {"completed": 1, "failed": 1} + run_index.delete_rows([a.run_id, b.run_id]) + rows, _ = run_index.query_runs(RunQuery(group_id="g1")) + assert rows == [] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest comfy_research/tests/test_run_index.py -v` +Expected: FAIL with module not found + +- [ ] **Step 3: Write the implementation** + +```python +# comfy_research/engine/runs/run_index.py +"""Rebuildable SQLite read-index over ``data/runs/*/run.json`` (never a truth source).""" +from __future__ import annotations + +import json +import logging +import sqlite3 +from dataclasses import dataclass, field +from pathlib import Path + +from comfy_research.engine.runs import run_store +from comfy_research.schemas.run_record import RunRecord, now_ms + +logger = logging.getLogger(__name__) + +_COLUMNS = ( + "run_id", "group_id", "parent_id", "origin", "status", "created_at", + "started_at", "finished_at", "last_heartbeat_at", "trainer_node_id", + "device", "error_detail", "hyperparams_json", "final_loss", + "final_test_loss", "best_test_loss", "steps_completed", "duration_seconds", +) +_ORDERABLE = {"created_at", "finished_at", "final_loss", "final_test_loss", + "best_test_loss", "steps_completed", "duration_seconds"} + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY, group_id TEXT, parent_id TEXT, + origin TEXT NOT NULL, status TEXT NOT NULL, + created_at REAL NOT NULL, started_at REAL, finished_at REAL, + last_heartbeat_at REAL, trainer_node_id TEXT NOT NULL, + device TEXT DEFAULT '', error_detail TEXT DEFAULT '', + hyperparams_json TEXT NOT NULL DEFAULT '{}', + final_loss REAL, final_test_loss REAL, best_test_loss REAL, + steps_completed INTEGER, duration_seconds REAL +); +CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status); +CREATE INDEX IF NOT EXISTS idx_runs_group ON runs(group_id); +CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at); +""" + + +def index_path() -> Path: + return run_store.runs_root() / "index.db" + + +def _connect() -> sqlite3.Connection: + index_path().parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(index_path(), timeout=5) + conn.execute("PRAGMA journal_mode=WAL") + conn.executescript(_SCHEMA) + return conn + + +def upsert_run(rec: RunRecord, summary: dict | None = None) -> None: + s = summary or {} + duration = ( + (rec.finished_at - rec.started_at) / 1000.0 + if rec.finished_at is not None and rec.started_at is not None else None + ) + try: + with _connect() as conn: + conn.execute( + f"INSERT OR REPLACE INTO runs ({','.join(_COLUMNS)}) " + f"VALUES ({','.join('?' * len(_COLUMNS))})", + (rec.run_id, rec.group_id, rec.parent_id, rec.origin, rec.status, + rec.created_at, rec.started_at, rec.finished_at, now_ms(), + rec.trainer_node_id, rec.device, rec.error_detail, + json.dumps(rec.hyperparams), s.get("final_loss"), + s.get("final_test_loss"), s.get("best_test_loss"), + s.get("steps_completed"), duration), + ) + except sqlite3.Error: + logger.warning("run index upsert failed for %s", rec.run_id, exc_info=True) + + +def touch_heartbeat(run_id: str, at_ms: float) -> None: + try: + with _connect() as conn: + conn.execute("UPDATE runs SET last_heartbeat_at=? WHERE run_id=?", (at_ms, run_id)) + except sqlite3.Error: + logger.warning("run index heartbeat failed for %s", run_id, exc_info=True) + + +@dataclass +class RunQuery: + status: str | None = None + origin: str | None = None + group_id: str | None = None + since_ms: float | None = None + ids: list[str] | None = None + hyperparams: dict[str, str] = field(default_factory=dict) + order_by: str = "-created_at" + limit: int = 100 + cursor: str | None = None + + +def _row_to_dict(row: tuple) -> dict: + d = dict(zip(_COLUMNS, row)) + d["hyperparams"] = json.loads(d.pop("hyperparams_json") or "{}") + return d + + +def query_runs(q: RunQuery) -> tuple[list[dict], str | None]: + key = q.order_by.lstrip("-") + if key not in _ORDERABLE: + key, q = "created_at", RunQuery(**{**q.__dict__, "order_by": "-created_at"}) + direction = "DESC" if q.order_by.startswith("-") else "ASC" + where, params = ["1=1"], [] + for col, val in (("status", q.status), ("origin", q.origin), ("group_id", q.group_id)): + if val is not None: + where.append(f"{col}=?") + params.append(val) + if q.since_ms is not None: + where.append("created_at>=?") + params.append(q.since_ms) + if q.ids: + where.append(f"run_id IN ({','.join('?' * len(q.ids))})") + params.extend(q.ids) + for hk, hv in q.hyperparams.items(): + where.append("CAST(json_extract(hyperparams_json, ?) AS TEXT)=?") + params.extend([f'$."{hk}"', hv]) + if q.cursor: + cv, cid = q.cursor.rsplit(":", 1) + op = "<" if direction == "DESC" else ">" + where.append(f"({key} {op} ? OR ({key} = ? AND run_id {op} ?))") + params.extend([float(cv), float(cv), cid]) + sql = (f"SELECT {','.join(_COLUMNS)} FROM runs WHERE {' AND '.join(where)} " + f"ORDER BY {key} {direction} NULLS LAST, run_id {direction} LIMIT ?") + limit = max(1, min(int(q.limit), 500)) + with _connect() as conn: + rows = [_row_to_dict(r) for r in conn.execute(sql, [*params, limit + 1])] + next_cursor = None + if len(rows) > limit: + rows = rows[:limit] + last = rows[-1] + next_cursor = f"{last[key]}:{last['run_id']}" + return rows, next_cursor + + +def group_summary() -> list[dict]: + with _connect() as conn: + raw = conn.execute( + "SELECT group_id, status, COUNT(*), MIN(best_test_loss), MIN(final_loss) " + "FROM runs WHERE group_id IS NOT NULL GROUP BY group_id, status" + ).fetchall() + groups: dict[str, dict] = {} + for gid, status, count, best_test, best_final in raw: + g = groups.setdefault(gid, {"group_id": gid, "counts": {}, + "best_test_loss": None, "best_final_loss": None}) + g["counts"][status] = count + for k, v in (("best_test_loss", best_test), ("best_final_loss", best_final)): + if v is not None and (g[k] is None or v < g[k]): + g[k] = v + return sorted(groups.values(), key=lambda g: g["group_id"]) + + +def delete_rows(run_ids: list[str]) -> None: + if not run_ids: + return + with _connect() as conn: + conn.execute(f"DELETE FROM runs WHERE run_id IN ({','.join('?' * len(run_ids))})", run_ids) + + +def rebuild_index() -> int: + root = run_store.runs_root() + count = 0 + if index_path().exists(): + index_path().unlink() + for entry in sorted(root.glob("run-*/run.json")): + run_id = entry.parent.name + rec = run_store.read_run_record(run_id) + if rec is None: + # Corrupted run.json: surface it, don't silently skip (spec: unreadable rows). + try: + with _connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO runs (run_id, origin, status, created_at, " + "trainer_node_id) VALUES (?, 'human', 'unreadable', 0, '')", + (run_id,), + ) + count += 1 + except sqlite3.Error: + logger.warning("could not index unreadable run %s", run_id, exc_info=True) + continue + _, series = run_store.load_series(rec.run_id) + upsert_run(rec, run_store.summarize(series)) + count += 1 + return count + + +def reconcile_stale_running(timeout_ms: float = 60_000) -> list[str]: + cutoff = now_ms() - timeout_ms + with _connect() as conn: + stale = [r[0] for r in conn.execute( + "SELECT run_id FROM runs WHERE status IN ('running','queued') " + "AND (last_heartbeat_at IS NULL OR last_heartbeat_at < ?)", (cutoff,) + )] + crashed = [] + for run_id in stale: + rec = run_store.read_run_record(run_id) + if rec is None or rec.status not in ("running", "queued"): + continue + rec = rec.model_copy(update={"status": "crashed", "finished_at": now_ms()}) + run_store.write_run_record(rec) + _, series = run_store.load_series(run_id) + upsert_run(rec, run_store.summarize(series)) + crashed.append(run_id) + return crashed +``` + +Note: SQLite < 3.30 lacks `NULLS LAST`; if CI's Python bundles an older SQLite the `ORDER BY` should become `ORDER BY ({key} IS NULL), {key} {direction}, run_id {direction}` — check `sqlite3.sqlite_version` and use the portable form directly if simpler. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest comfy_research/tests/test_run_index.py -v` +Expected: PASS (7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add comfy_research/engine/runs/run_index.py comfy_research/tests/test_run_index.py +git commit -m "feat: add rebuildable SQLite run index with cursor queries and reconciliation" +``` + +--- + +### Task 4: RunWriter facade + capture_events + +**Files:** +- Create: `comfy_research/engine/runs/run_writer.py` +- Test: `comfy_research/tests/test_run_writer.py` + +**Interfaces:** +- Consumes: Tasks 1–3. +- Produces: + - `class RunWriter`: `__init__(self, record: RunRecord)` (persists record + index row), `mark_running() -> None`, `on_event(event: dict) -> None` (delta append, coalesced heartbeat ≥1 s, terminal handling), `finalize(status: str, error_detail: str = "") -> None` (idempotent), `finalize_disconnect() -> None` (→ `aborted` if not yet terminal), property `is_terminal: bool`, property `record: RunRecord`. + - `capture_events(events: Iterator[dict], writer: RunWriter) -> Iterator[dict]`. +- Terminal mapping inside `on_event`: `complete → completed` (results.json from event payload), `aborted → aborted`, `paused → paused` (results.json from event payload), `error → failed` (results.json from tracker snapshot, `error_detail` from event). `aborted` uses tracker snapshot for results.json. + +- [ ] **Step 1: Write the failing test** + +```python +# comfy_research/tests/test_run_writer.py +from __future__ import annotations + +from comfy_research.schemas.graph import GraphDocument +from comfy_research.schemas.run_record import RunRecord, new_run_id, now_ms +from comfy_research.engine.runs import run_index, run_store +from comfy_research.engine.runs.run_index import RunQuery +from comfy_research.engine.runs.run_writer import RunWriter, capture_events + + +def _writer(monkeypatch, tmp_path) -> RunWriter: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + return RunWriter(RunRecord( + run_id=new_run_id(), origin="agent", status="queued", created_at=now_ms(), + trainer_node_id="t1", graph=GraphDocument(version=1, nodes=[], edges=[]), + )) + + +def _events() -> list[dict]: + return [ + {"type": "progress", "step": 0, "total": 2}, + {"type": "metrics", "step": 2, "loss_history": [1.0, 0.5], + "test_loss_history": [], "reg_loss_history": [], "step_ticks": [0, 1], + "epoch_ticks": [], "observable_metric_histories": {}, "observable_warnings": {}}, + {"type": "complete", "checkpoint_b64": "QUJD", "plot_png_base64": "aW1n", + "loss_history": [1.0, 0.5, 0.25], "test_loss_history": [], + "reg_loss_history": [], "step_ticks": [0, 1, 2], "epoch_ticks": [], + "observable_viz_updates": [], "observable_metric_histories": {}, + "observable_embedding_histories": {}, "observable_attention_slice_histories": {}, + "observable_warnings": {}, "train_loop_seconds": 0.1, + "visualization_node_ids": []}, + ] + + +def test_complete_flow(monkeypatch, tmp_path) -> None: + w = _writer(monkeypatch, tmp_path) + seen = list(capture_events(iter(_events()), w)) + assert [e["type"] for e in seen] == ["progress", "metrics", "complete"] + rec = run_store.read_run_record(w.record.run_id) + assert rec.status == "completed" and rec.started_at and rec.finished_at + res = run_store.read_results(rec.run_id) + assert res["loss_history"] == [1.0, 0.5, 0.25] + assert "checkpoint_b64" not in res and "plot_png_base64" not in res + rows, _ = run_index.query_runs(RunQuery(ids=[rec.run_id])) + assert rows[0]["status"] == "completed" and rows[0]["final_loss"] == 0.25 + assert len(run_store.read_metric_rows(rec.run_id)) == 2 + + +def test_disconnect_mid_stream_finalizes_aborted(monkeypatch, tmp_path) -> None: + w = _writer(monkeypatch, tmp_path) + gen = capture_events(iter(_events()), w) + next(gen) + next(gen) # consumed progress + metrics, then client goes away + gen.close() + rec = run_store.read_run_record(w.record.run_id) + assert rec.status == "aborted" + assert run_store.read_results(rec.run_id)["loss_history"] == [1.0, 0.5] + + +def test_error_event_finalizes_failed(monkeypatch, tmp_path) -> None: + w = _writer(monkeypatch, tmp_path) + list(capture_events(iter([{"type": "error", "detail": "boom"}]), w)) + rec = run_store.read_run_record(w.record.run_id) + assert rec.status == "failed" and rec.error_detail == "boom" + + +def test_finalize_idempotent(monkeypatch, tmp_path) -> None: + w = _writer(monkeypatch, tmp_path) + list(capture_events(iter(_events()), w)) + w.finalize_disconnect() # no-op after completed + assert run_store.read_run_record(w.record.run_id).status == "completed" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest comfy_research/tests/test_run_writer.py -v` +Expected: FAIL with module not found + +- [ ] **Step 3: Write the implementation** + +```python +# comfy_research/engine/runs/run_writer.py +"""RunWriter: persist one training run's lifecycle from its NDJSON event stream.""" +from __future__ import annotations + +import logging +from typing import Any, Iterator + +from comfy_research.engine.runs import run_index, run_store +from comfy_research.schemas.run_record import RunRecord, TERMINAL_STATUSES, now_ms + +logger = logging.getLogger(__name__) + +_HEARTBEAT_MIN_INTERVAL_MS = 1000.0 +_TERMINAL_EVENT_STATUS = {"complete": "completed", "aborted": "aborted", + "paused": "paused", "error": "failed"} + + +class RunWriter: + def __init__(self, record: RunRecord) -> None: + self._record = record + self._tracker = run_store.MetricsDeltaTracker() + self._last_heartbeat = 0.0 + run_store.write_run_record(record) + run_index.upsert_run(record) + + @property + def record(self) -> RunRecord: + return self._record + + @property + def is_terminal(self) -> bool: + return self._record.status in TERMINAL_STATUSES + + def _update(self, **changes: Any) -> None: + self._record = self._record.model_copy(update=changes) + run_store.write_run_record(self._record) + + def mark_running(self) -> None: + if self._record.status == "queued": + self._update(status="running", started_at=now_ms()) + run_index.upsert_run(self._record) + + def on_event(self, event: dict) -> None: + if self.is_terminal: + return + etype = str(event.get("type", "")) + rows = self._tracker.extract(event) + if rows: + try: + run_store.append_metric_rows(self._record.run_id, rows) + except OSError: + logger.warning("metric append failed for %s", self._record.run_id, exc_info=True) + now = now_ms() + if now - self._last_heartbeat >= _HEARTBEAT_MIN_INTERVAL_MS: + run_index.touch_heartbeat(self._record.run_id, now) + self._last_heartbeat = now + status = _TERMINAL_EVENT_STATUS.get(etype) + if status is None: + return + if etype in ("complete", "paused"): + run_store.write_results(self._record.run_id, dict(event)) + else: + run_store.write_results(self._record.run_id, self._tracker.snapshot()) + detail = str(event.get("detail", "")) if etype == "error" else "" + self.finalize(status, error_detail=detail) + + def finalize(self, status: str, error_detail: str = "") -> None: + if self.is_terminal: + return + self._update(status=status, finished_at=now_ms(), error_detail=error_detail) + snap = run_store.read_results(self._record.run_id) or self._tracker.snapshot() + run_index.upsert_run(self._record, run_store.summarize(snap)) + + def finalize_disconnect(self) -> None: + if not self.is_terminal: + run_store.write_results(self._record.run_id, self._tracker.snapshot()) + self.finalize("aborted") + + +def capture_events(events: Iterator[dict], writer: RunWriter) -> Iterator[dict]: + """Tee events into the writer; disconnect (generator close) finalizes as aborted.""" + writer.mark_running() + try: + for event in events: + try: + writer.on_event(event) + except Exception: + logger.warning("run capture failed for %s", writer.record.run_id, exc_info=True) + yield event + finally: + writer.finalize_disconnect() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest comfy_research/tests/test_run_writer.py -v` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add comfy_research/engine/runs/run_writer.py comfy_research/tests/test_run_writer.py +git commit -m "feat: add RunWriter facade with capture_events tee" +``` + +--- + +### Task 5: Capture in `/api/train` (local + remote) and TrainRequest run fields + +**Files:** +- Modify: `comfy_research/schemas/train_request.py` (add fields to `TrainRequest`) +- Modify: `comfy_research/api/train.py:359-463` (`post_train`) +- Test: `comfy_research/tests/test_train_run_capture.py` + +**Interfaces:** +- Consumes: `RunWriter`, `capture_events`, `RunRecord`, `new_run_id`, `now_ms`, `strip_result_data`, `flatten_hyperparams`, `GraphDocument`. +- Produces: `TrainRequest.run_origin: Literal["human","agent","sweep"] = "human"`, `run_group_id: str | None = None`, `run_parent_id: str | None = None`; helper `build_run_record(body: TrainRequest, *, origin=None, group_id=None, status="queued") -> RunRecord` in `run_writer.py`; `/api/train` emits `{"type": "run_registered", "run_id": ...}` before any training event — on the local classic path it is the FIRST event; on the remote path it comes after the bootstrap/`phase` events (which are emitted before validation completes); the CRL path emits none (v1 exclusion). +- Frontend stream parser: `frontend/src/graph/readNdjsonTrainStream.ts` routes unknown event types through its progress-callback fallback — add an explicit `run_registered` branch that ignores the event (or records the id for later use), so it never reaches the fallback. + +- [ ] **Step 1: Write the failing test** + +```python +# comfy_research/tests/test_train_run_capture.py +from __future__ import annotations + +import json + +from fastapi.testclient import TestClient + +from comfy_research.main import app +from comfy_research.engine.runs import run_index, run_store +from comfy_research.engine.runs.run_index import RunQuery +from comfy_research.tests.train_test_fixtures import minimal_cpu_train_request + + +def _events(text: str) -> list[dict]: + return [json.loads(line) for line in text.splitlines() if line.strip()] + + +def test_post_train_registers_and_persists_run(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + body = {**minimal_cpu_train_request(), "run_origin": "agent", "run_group_id": "g1"} + response = TestClient(app).post("/api/train", json=body) + assert response.status_code == 200 + events = _events(response.text) + assert events[0]["type"] == "run_registered" + run_id = events[0]["run_id"] + assert any(e["type"] == "complete" for e in events) + + rec = run_store.read_run_record(run_id) + assert rec.status == "completed" + assert rec.origin == "agent" and rec.group_id == "g1" + assert rec.graph.nodes # config snapshot present + for node in rec.graph.nodes: + assert "memoryCheckpoint_b64" not in (node.data or {}) + res = run_store.read_results(run_id) + assert len(res["loss_history"]) == 4 and "checkpoint_b64" not in res + rows, _ = run_index.query_runs(RunQuery(ids=[run_id])) + assert rows[0]["status"] == "completed" and rows[0]["hyperparams"] + + +def test_post_train_default_origin_human(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + response = TestClient(app).post("/api/train", json=minimal_cpu_train_request()) + run_id = _events(response.text)[0]["run_id"] + assert run_store.read_run_record(run_id).origin == "human" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest comfy_research/tests/test_train_run_capture.py -v` +Expected: FAIL — first event is `progress`, not `run_registered` + +- [ ] **Step 3: Implement** + +In `comfy_research/schemas/train_request.py`, extend `TrainRequest`: + +```python +class TrainRequest(BaseModel): + trainer_node_id: str + nodes: list[Node] = Field(default_factory=list) + edges: list[Edge] = Field(default_factory=list) + resume: dict[str, Any] | None = None + hessian_oversized_policy: Literal["skip", "force"] | None = None + run_origin: Literal["human", "agent", "sweep"] = "human" + run_group_id: str | None = None + run_parent_id: str | None = None +``` + +Add to `comfy_research/engine/runs/run_writer.py` (imports: `TrainRequest`, `GraphDocument`, `new_run_id`, `strip_result_data`, `flatten_hyperparams` from `comfy_research.schemas.run_record`): + +```python +def build_run_record( + body: TrainRequest, + *, + origin: str | None = None, + group_id: str | None = None, + status: str = "queued", +) -> RunRecord: + nodes = strip_result_data(body.nodes) + trainer = next((n for n in body.nodes if n.id == body.trainer_node_id), None) + device = str((trainer.data or {}).get("computeDevice", "")) if trainer else "" + return RunRecord( + run_id=new_run_id(), + origin=origin or body.run_origin, + group_id=group_id if group_id is not None else body.run_group_id, + parent_id=body.run_parent_id, + status=status, + created_at=now_ms(), + trainer_node_id=body.trainer_node_id, + device=device, + graph=GraphDocument(version=1, nodes=nodes, edges=body.edges), + hyperparams=flatten_hyperparams(body.nodes), + ) +``` + +In `comfy_research/api/train.py`, add imports: + +```python +from comfy_research.engine.runs.run_writer import RunWriter, build_run_record, capture_events +``` + +Replace the local classic-path generator (currently lines 455-457): + +```python + writer = RunWriter(build_run_record(body)) + + def generate(): + yield _ndjson_encode({"type": "run_registered", "run_id": writer.record.run_id}) + for event in capture_events(iter_trainer_events_from_context(ctx), writer): + yield _ndjson_encode(event) +``` + +For the remote path, `iter_remote_train_stdout_lines` yields already-encoded NDJSON lines; wrap `generate_remote`'s final `yield from` (line 415): + +```python + writer = RunWriter(build_run_record(body)) + yield _ndjson_encode({"type": "run_registered", "run_id": writer.record.run_id}) + writer.mark_running() + try: + for raw in iter_remote_train_stdout_lines(body, config=cfg_holder["cfg"]): + try: + writer.on_event(json.loads(bytes(raw).decode("utf-8"))) + except Exception: + pass # unparseable remote line: forward but don't capture + yield raw + finally: + writer.finalize_disconnect() +``` + +Confirm `iter_remote_train_stdout_lines` yields `bytes` (check `comfy_research/remote/ssh.py`); if it yields `str`, drop the `.decode`. The CRL path (`generate_crl`) is left untouched (v1 exclusion). The `run_registered` event goes AFTER `prepare_trainer_run` so invalid graphs still 400 without creating a run record. + +Also update `frontend/src/graph/readNdjsonTrainStream.ts`: add a `run_registered` case that is a no-op (before the fallback branch that currently funnels unknown types into the progress callback), plus modify Task 5's files list to include it in the commit. + +- [ ] **Step 4: Run new and existing tests** + +Run: `python -m pytest comfy_research/tests/test_train_run_capture.py comfy_research/tests/test_train_api_integration.py -v` +Expected: the two new tests PASS. `test_post_train_streams_real_cpu_training_result` will FAIL on `assert [event["step"] for event in progress] == [0, 1, 2, 3]` only if `run_registered` broke its event parsing — it filters by type, so it should PASS; if any existing assertion trips over the new first event, update that assertion to skip `run_registered`. + +- [ ] **Step 5: Commit** + +```bash +git add comfy_research/schemas/train_request.py comfy_research/api/train.py \ + comfy_research/engine/runs/run_writer.py comfy_research/tests/test_train_run_capture.py +git commit -m "feat: capture /api/train runs (local and remote) into the run store" +``` + +--- + +### Task 6: Sweep inner-run capture + +**Files:** +- Modify: `comfy_research/engine/runs/train_sweep.py:234-323` (`iter_sweep_events`) +- Test: `comfy_research/tests/test_sweep_run_capture.py` + +**Interfaces:** +- Consumes: `RunWriter`, `build_run_record`, `capture_events`, `TrainRequest`. +- Produces: each non-CRL sweep combo persisted as its own run with `origin="sweep"`, `group_id=`; wrapper events unchanged. + +- [ ] **Step 1: Write the failing test** + +```python +# comfy_research/tests/test_sweep_run_capture.py +from __future__ import annotations + +import json + +from fastapi.testclient import TestClient + +from comfy_research.main import app +from comfy_research.engine.runs import run_index +from comfy_research.engine.runs.run_index import RunQuery +from comfy_research.tests.train_test_fixtures import minimal_cpu_train_request + + +def test_sweep_inner_runs_captured_with_group(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + base = minimal_cpu_train_request() + body = { + "sweep_session_id": "sweep-test-1", + "trainer_node_id": base["trainer_node_id"], + "nodes": base["nodes"], + "edges": base["edges"], + "axes": [{"node_id": base["trainer_node_id"], "data_path": "trainingSteps", + "values": [2, 3]}], + "metric": {"kind": "final_train_loss"}, + } + response = TestClient(app).post("/api/train/sweep", json=body) + assert response.status_code == 200 + events = [json.loads(l) for l in response.text.splitlines() if l.strip()] + assert any(e["type"] == "sweep_complete" for e in events) + + rows, _ = run_index.query_runs(RunQuery(group_id="sweep-test-1")) + assert len(rows) == 2 + assert all(r["origin"] == "sweep" and r["status"] == "completed" for r in rows) + steps = sorted(r["hyperparams"][f"{base['trainer_node_id']}.trainingSteps"] for r in rows) + assert steps == [2, 3] +``` + +Before finalizing the test, read `SweepAxis` (`train_sweep.py:26-45`) and copy its exact field names for the axes payload (`node_id` / `data_path` / `values` — adjust to the real names). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest comfy_research/tests/test_sweep_run_capture.py -v` +Expected: FAIL — `query_runs` returns 0 rows + +- [ ] **Step 3: Implement** + +In `train_sweep.py`, inside `iter_sweep_events`'s combo loop, wrap the classic (non-CRL) branch. Current code: + +```python + ctx_sup = prepare_trainer_run(...) + event_iter = iter_trainer_events_from_context(ctx_sup) +``` + +becomes: + +```python + ctx_sup = prepare_trainer_run( + nodes_p, + body.edges, + body.trainer_node_id, + resume=None, + hessian_oversized_policy="skip", + ) + inner_req = TrainRequest( + trainer_node_id=body.trainer_node_id, + nodes=nodes_p, + edges=body.edges, + ) + inner_writer = RunWriter( + build_run_record(inner_req, origin="sweep", group_id=session_id) + ) + event_iter = capture_events( + iter_trainer_events_from_context(ctx_sup), inner_writer + ) +``` + +Imports at top of `train_sweep.py`: + +```python +from comfy_research.engine.runs.run_writer import RunWriter, build_run_record, capture_events +from comfy_research.schemas.train_request import TrainRequest +``` + +The sweep loop `break`s out of `event_iter` on `complete`/`aborted`/`paused` without exhausting it; `capture_events` still finalizes because `on_event` marks terminal before the break, and generator GC triggers the `finally` for the error path. To make finalization deterministic (not GC-dependent), close explicitly: after the `for ev in event_iter:` loop add `event_iter.close()` (guard with `if hasattr(event_iter, "close")` since the CRL branch stays unwrapped). + +Apply the identical wrapping to `comfy_research/engine/runs/train_coordinate_descent.py` (~line 208), where `iter_trainer_events_from_context` is consumed the same way inside the tuning loop: build an `inner_req = TrainRequest(trainer_node_id=..., nodes=, edges=...)` with the actual local variable names at that site, use `origin="sweep"` and `group_id=`, and add the same `event_iter.close()` guard. If that file's inner consumption differs structurally (e.g. no reusable event iterator), capture only the sweep path in this task and record coordinate descent as a follow-up item in the plan's final commit message — do not leave it silently uncaptured. + +- [ ] **Step 4: Run tests** + +Run: `python -m pytest comfy_research/tests/test_sweep_run_capture.py comfy_research/tests/test_sweep_api.py -v` (if `test_sweep_api.py` doesn't exist, run `python -m pytest comfy_research/tests -k sweep -v`) +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add comfy_research/engine/runs/train_sweep.py comfy_research/tests/test_sweep_run_capture.py +git commit -m "feat: capture sweep inner runs into the run store with sweep session grouping" +``` + +--- + +### Task 7: Async-submit worker pool + +**Files:** +- Create: `comfy_research/engine/runs/run_worker.py` +- Test: `comfy_research/tests/test_run_worker.py` + +**Interfaces:** +- Consumes: `prepare_trainer_run`, `iter_trainer_events_from_context`, `request_abort` (`train_control.py`), `RunWriter`, `build_run_record`, `capture_events`, `TrainRequest`. +- Produces: `class RunWorkerPool`: `submit(body: TrainRequest, idempotency_key: str | None = None) -> RunRecord` (validates via `prepare_trainer_run(..., validate_only=True)` with the request's `resume` and `hessian_oversized_policy` so validation matches execution — raises `HTTPException` upward on bad graph; returns existing record for a known idempotency key), `abort(run_id: str) -> bool`, `shutdown(wait: bool = False) -> None`; module singleton `get_worker_pool() -> RunWorkerPool` and `reset_worker_pool_for_tests() -> None`. +- Same-trainer serialization WITHOUT occupying executor slots: a run whose `trainer_node_id` is already executing waits in a per-trainer FIFO (plain data, no thread); when the active run finishes, the pool submits the next queued one. A waiting run never blocks a worker thread, so different-trainer runs always get slots (no head-of-line blocking). +- Abort is `run_id`-scoped: a run waiting in the FIFO is removed and finalized `aborted` directly (no `request_abort`); `request_abort(trainer_node_id)` is only issued when the pool's currently-executing run for that trainer IS this `run_id` — never for a queued run, so it cannot cross-abort another run. (Caveat, documented: the underlying `train_control` registry is a single slot per trainer id, so a concurrent browser `/api/train` on the SAME trainer id could still receive the signal — pre-existing product semantics; and an abort landing before the training loop registers the trainer is lost, cooperative-abort semantics as today.) +- Submit rejects remote-GPU graphs (`HTTPException(400, {"code": "remote_not_supported", ...})`) — remote runs go through streaming `/api/train`. + +- [ ] **Step 1: Write the failing test** + +```python +# comfy_research/tests/test_run_worker.py +from __future__ import annotations + +import time + +import pytest +from fastapi import HTTPException + +from comfy_research.engine.runs import run_store +from comfy_research.engine.runs.run_worker import get_worker_pool, reset_worker_pool_for_tests +from comfy_research.schemas.train_request import TrainRequest +from comfy_research.tests.train_test_fixtures import minimal_cpu_train_request + + +def _wait_terminal(run_id: str, timeout_s: float = 30.0) -> str: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + rec = run_store.read_run_record(run_id) + if rec and rec.status not in ("queued", "running"): + return rec.status + time.sleep(0.05) + raise AssertionError(f"run {run_id} never reached terminal state") + + +@pytest.fixture(autouse=True) +def _isolated(tmp_path, monkeypatch): + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + reset_worker_pool_for_tests() + yield + reset_worker_pool_for_tests() + + +def test_submit_runs_to_completion() -> None: + body = TrainRequest.model_validate({**minimal_cpu_train_request(), "run_origin": "agent"}) + rec = get_worker_pool().submit(body) + assert rec.status == "queued" and rec.origin == "agent" + assert _wait_terminal(rec.run_id) == "completed" + assert len(run_store.read_results(rec.run_id)["loss_history"]) == 4 + + +def test_submit_invalid_graph_raises_400_and_persists_nothing(tmp_path) -> None: + body = TrainRequest.model_validate({ + "trainer_node_id": "trainer", + "nodes": [{"id": "trainer", "type": "trainer", + "data": {"trainingSteps": 1, "computeDevice": "cpu"}}], + "edges": [], + }) + with pytest.raises(HTTPException) as exc: + get_worker_pool().submit(body) + assert exc.value.status_code == 400 + assert not list(tmp_path.glob("run-*")) + + +def test_idempotency_key_returns_same_run() -> None: + body = TrainRequest.model_validate(minimal_cpu_train_request()) + a = get_worker_pool().submit(body, idempotency_key="k1") + b = get_worker_pool().submit(body, idempotency_key="k1") + assert a.run_id == b.run_id + _wait_terminal(a.run_id) + + +def test_same_trainer_runs_serialize() -> None: + pool = get_worker_pool() + body = TrainRequest.model_validate(minimal_cpu_train_request()) + a = pool.submit(body) + b = pool.submit(body) + assert _wait_terminal(a.run_id) == "completed" + assert _wait_terminal(b.run_id) == "completed" + + +def test_abort_waiting_run_is_scoped_to_that_run() -> None: + pool = get_worker_pool() + slow = minimal_cpu_train_request() + for n in slow["nodes"]: + if n["id"] == slow["trainer_node_id"]: + n["data"] = {**n["data"], "trainingSteps": 2000} + a = pool.submit(TrainRequest.model_validate(slow)) + b = pool.submit(TrainRequest.model_validate(slow)) # waits in FIFO behind a (same trainer) + assert pool.abort(b.run_id) is True + # b dies immediately, without any train_control signal that could hit a + assert run_store.read_run_record(b.run_id).status == "aborted" + assert run_store.read_run_record(a.run_id).status in ("queued", "running") + # abort the running run; cooperative signal can land before the training loop + # registers the trainer, so retry until it takes effect + deadline = time.monotonic() + 30.0 + while time.monotonic() < deadline: + pool.abort(a.run_id) + rec = run_store.read_run_record(a.run_id) + if rec.status not in ("queued", "running"): + break + time.sleep(0.1) + assert run_store.read_run_record(a.run_id).status == "aborted" + assert pool.abort("run-nonexistent0") is False + + +def _renamed_fixture(suffix: str) -> dict: + """Same minimal graph under fresh ids, so it counts as a different trainer.""" + req = minimal_cpu_train_request() + for n in req["nodes"]: + n["id"] = n["id"] + suffix + for e in req["edges"]: + e["id"] = e["id"] + suffix + e["source"] = e["source"] + suffix + e["target"] = e["target"] + suffix + req["trainer_node_id"] = req["trainer_node_id"] + suffix + return req + + +def test_different_trainer_not_blocked_by_same_trainer_queue() -> None: + pool = get_worker_pool() + slow = minimal_cpu_train_request() + for n in slow["nodes"]: + if n["id"] == slow["trainer_node_id"]: + n["data"] = {**n["data"], "trainingSteps": 2000} + a = pool.submit(TrainRequest.model_validate(slow)) + b = pool.submit(TrainRequest.model_validate(slow)) # waits in FIFO, occupies no slot + fast = pool.submit(TrainRequest.model_validate(_renamed_fixture("-x"))) + # with head-of-line blocking, `fast` would be stuck behind b in the 2-slot pool + assert _wait_terminal(fast.run_id) == "completed" + for rid in (a.run_id, b.run_id): + pool.abort(rid) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest comfy_research/tests/test_run_worker.py -v` +Expected: FAIL with module not found + +- [ ] **Step 3: Write the implementation** + +```python +# comfy_research/engine/runs/run_worker.py +"""Server-owned worker pool: async-submitted runs execute detached from any HTTP stream.""" +from __future__ import annotations + +import logging +import threading +from collections import deque +from concurrent.futures import ThreadPoolExecutor + +from fastapi import HTTPException + +from comfy_research.engine.runs.run_writer import RunWriter, build_run_record, capture_events +from comfy_research.engine.runs.trainer_run import ( + iter_trainer_events_from_context, + prepare_trainer_run, +) +from comfy_research.engine.runs.train_control import request_abort +from comfy_research.engine.runs.ai4science_alias import remap_ai4science_node_types +from comfy_research.schemas.train_request import TrainRequest + +logger = logging.getLogger(__name__) + +_DEFAULT_SLOTS = 2 + + +def _prefers_remote_gpu(body: TrainRequest) -> bool: + node = next((n for n in body.nodes if n.id == body.trainer_node_id), None) + data = getattr(node, "data", None) or {} + spec = str(data.get("computeDevice", "")).strip().lower() + return (spec == "cuda" or spec.startswith("cuda:")) and data.get("remoteGpu") is True + + +class RunWorkerPool: + def __init__(self, slots: int = _DEFAULT_SLOTS) -> None: + self._executor = ThreadPoolExecutor(max_workers=slots, thread_name_prefix="run-worker") + self._lock = threading.Lock() + self._active: dict[str, str] = {} # trainer_node_id -> run_id currently executing + self._waiting: dict[str, deque[str]] = {} # trainer_node_id -> queued run_ids (FIFO) + self._writers: dict[str, RunWriter] = {} + self._idempotency: dict[str, str] = {} + self._records: dict[str, TrainRequest] = {} + + def submit(self, body: TrainRequest, idempotency_key: str | None = None): + if _prefers_remote_gpu(body): + raise HTTPException(status_code=400, detail={ + "code": "remote_not_supported", + "detail": "Async submit runs locally only; use streaming POST /api/train for remote GPU runs.", + }) + with self._lock: + if idempotency_key and idempotency_key in self._idempotency: + run_id = self._idempotency[idempotency_key] + return self._writers[run_id].record + mapped = remap_ai4science_node_types(body.nodes) + prepare_trainer_run( + mapped, body.edges, body.trainer_node_id, + resume=body.resume, + hessian_oversized_policy=body.hessian_oversized_policy, + validate_only=True, + ) + writer = RunWriter(build_run_record(body)) + run_id = writer.record.run_id + trainer_id = body.trainer_node_id + with self._lock: + self._writers[run_id] = writer + self._records[run_id] = body + if idempotency_key: + self._idempotency[idempotency_key] = run_id + if trainer_id in self._active: + # Same trainer already executing: wait in FIFO (no thread blocked), + # because the train_control registry is a single slot per trainer id. + self._waiting.setdefault(trainer_id, deque()).append(run_id) + return writer.record + self._active[trainer_id] = run_id + self._executor.submit(self._execute, run_id) + return writer.record + + def _execute(self, run_id: str) -> None: + with self._lock: + writer = self._writers.get(run_id) + body = self._records.get(run_id) + if writer is None or body is None: + return + try: + if not writer.is_terminal: + mapped = remap_ai4science_node_types(body.nodes) + ctx = prepare_trainer_run( + mapped, body.edges, body.trainer_node_id, + resume=body.resume, + hessian_oversized_policy=body.hessian_oversized_policy, + ) + for _ in capture_events(iter_trainer_events_from_context(ctx), writer): + pass + except HTTPException as exc: + writer.finalize("failed", error_detail=str(exc.detail)) + except Exception as exc: + logger.warning("submitted run %s crashed", run_id, exc_info=True) + writer.finalize("failed", error_detail=f"{type(exc).__name__}: {exc}") + finally: + self._dispatch_next(body.trainer_node_id) + + def _dispatch_next(self, trainer_id: str) -> None: + with self._lock: + queue = self._waiting.get(trainer_id) + next_id = None + while queue: + candidate = queue.popleft() + w = self._writers.get(candidate) + if w is not None and not w.is_terminal: + next_id = candidate + break + if next_id is None: + self._active.pop(trainer_id, None) + if queue is not None and not queue: + self._waiting.pop(trainer_id, None) + return + self._active[trainer_id] = next_id + self._executor.submit(self._execute, next_id) + + def abort(self, run_id: str) -> bool: + with self._lock: + writer = self._writers.get(run_id) + body = self._records.get(run_id) + if writer is None or body is None or writer.is_terminal: + return False + trainer_id = body.trainer_node_id + queue = self._waiting.get(trainer_id) + if queue is not None and run_id in queue: + queue.remove(run_id) # waiting run: finalize directly, no train_control signal + writer.finalize("aborted") + return True + is_running_here = self._active.get(trainer_id) == run_id + if not is_running_here: + return False + request_abort(trainer_id) # only when THIS run holds the trainer; cooperative + return True + + def shutdown(self, wait: bool = False) -> None: + self._executor.shutdown(wait=wait, cancel_futures=True) + + +_pool: RunWorkerPool | None = None +_pool_lock = threading.Lock() + + +def get_worker_pool() -> RunWorkerPool: + global _pool + with _pool_lock: + if _pool is None: + _pool = RunWorkerPool() + return _pool + + +def reset_worker_pool_for_tests() -> None: + global _pool + with _pool_lock: + if _pool is not None: + _pool.shutdown(wait=True) + _pool = None +``` + +Known v1 limits (documented, accepted): idempotency map is in-memory (lost on restart); an abort signal landing before the training loop registers the trainer is lost (cooperative abort — same `/api/train/control` semantics; the API test retries); a concurrent browser `/api/train` on the same trainer id shares the single `train_control` slot with the pool's active run (pre-existing product behavior). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest comfy_research/tests/test_run_worker.py -v` +Expected: PASS (6 tests; the abort test may take ~10 s) + +- [ ] **Step 5: Commit** + +```bash +git add comfy_research/engine/runs/run_worker.py comfy_research/tests/test_run_worker.py +git commit -m "feat: add async-submit run worker pool with per-trainer serialization" +``` + +--- + +### Task 8: `/api/runs` router + startup wiring + +**Files:** +- Create: `comfy_research/api/runs.py` +- Modify: `comfy_research/main.py` (router registration ~line 104-125; startup reconciliation) +- Test: `comfy_research/tests/test_runs_api.py` + +**Interfaces:** +- Consumes: Tasks 1–4, 7 (`run_store`, `run_index`, `RunQuery`, `get_worker_pool`). +- Produces routes (all JSON): + - `POST /api/runs` — body `TrainRequest`; 202 `{"run_id", "status"}`; honors `Idempotency-Key` header. + - `GET /api/runs` — query params `status, origin, group_id, since, ids (comma-sep), order_by, limit, cursor`, plus any `hyperparam.=`; returns `{"runs": [...], "next_cursor": str | null}`. + - `GET /api/runs/groups` — `{"groups": [...]}` (registered before the `/{run_id}` route). + - `GET /api/runs/{run_id}` — RunRecord JSON + `"summary"`; 404 `{"code": "run_not_found", ...}`. + - `GET /api/runs/{run_id}/metrics` — `{"source": "results"|"ndjson", "data": {...}, "downsampled": bool}`; `?downsample=N` keeps every ceil(len/N)-th point of each series. + - `POST /api/runs/{run_id}/abort` — `{"ok": bool}`; 404 if unknown to the pool. + - `DELETE /api/runs/{run_id}` — 409 `{"code": "run_active", ...}` for `queued`/`running`; else removes dir + index row. + - `DELETE /api/runs` — same query filters; 400 `{"code": "filter_required", ...}` if no filter given; deletes terminal-state matches only; returns `{"deleted": int}`. + +- [ ] **Step 1: Write the failing test** + +```python +# comfy_research/tests/test_runs_api.py +from __future__ import annotations + +import time + +import pytest +from fastapi.testclient import TestClient + +from comfy_research.main import app +from comfy_research.engine.runs import run_index, run_store +from comfy_research.engine.runs.run_worker import reset_worker_pool_for_tests +from comfy_research.schemas.graph import GraphDocument +from comfy_research.schemas.run_record import RunRecord, new_run_id, now_ms +from comfy_research.tests.train_test_fixtures import minimal_cpu_train_request + + +@pytest.fixture(autouse=True) +def _isolated(tmp_path, monkeypatch): + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + reset_worker_pool_for_tests() + yield + reset_worker_pool_for_tests() + + +def _seed_terminal(status: str = "completed", group: str | None = None) -> str: + rec = RunRecord( + run_id=new_run_id(), origin="agent", status=status, created_at=now_ms(), + finished_at=now_ms(), group_id=group, trainer_node_id="t1", + graph=GraphDocument(version=1, nodes=[], edges=[]), + hyperparams={"t1.trainingSteps": 4}, + ) + run_store.write_run_record(rec) + run_store.write_results(rec.run_id, {"loss_history": [1.0, 0.5], + "test_loss_history": [], "step_ticks": [0, 1]}) + run_index.upsert_run(rec, {"final_loss": 0.5, "final_test_loss": None, + "best_test_loss": None, "steps_completed": 2}) + return rec.run_id + + +def _wait_terminal(client: TestClient, run_id: str, timeout_s: float = 30.0) -> str: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + status = client.get(f"/api/runs/{run_id}").json()["status"] + if status not in ("queued", "running"): + return status + time.sleep(0.05) + raise AssertionError("never terminal") + + +def test_submit_then_poll_lifecycle() -> None: + client = TestClient(app) + resp = client.post("/api/runs", json={**minimal_cpu_train_request(), "run_origin": "agent"}) + assert resp.status_code == 202 + run_id = resp.json()["run_id"] + assert _wait_terminal(client, run_id) == "completed" + metrics = client.get(f"/api/runs/{run_id}/metrics").json() + assert metrics["source"] == "results" + assert len(metrics["data"]["loss_history"]) == 4 + + +def test_submit_invalid_graph_400() -> None: + resp = TestClient(app).post("/api/runs", json={ + "trainer_node_id": "trainer", + "nodes": [{"id": "trainer", "type": "trainer", + "data": {"trainingSteps": 1, "computeDevice": "cpu"}}], + "edges": [], + }) + assert resp.status_code == 400 + + +def test_list_filters_and_hyperparam() -> None: + client = TestClient(app) + a = _seed_terminal("completed", group="g1") + _seed_terminal("failed", group="g1") + body = client.get("/api/runs", params={"status": "completed"}).json() + assert [r["run_id"] for r in body["runs"]] == [a] + body = client.get("/api/runs", params={"hyperparam.t1.trainingSteps": "4"}).json() + assert len(body["runs"]) == 2 + groups = client.get("/api/runs/groups").json()["groups"] + assert groups[0]["group_id"] == "g1" and groups[0]["counts"]["failed"] == 1 + + +def test_get_missing_run_404_with_code() -> None: + resp = TestClient(app).get("/api/runs/run-doesnotexist") + assert resp.status_code == 404 + assert resp.json()["detail"]["code"] == "run_not_found" + + +def test_delete_guards() -> None: + client = TestClient(app) + rid = _seed_terminal("completed") + assert client.delete("/api/runs").status_code == 400 + running = RunRecord( + run_id=new_run_id(), origin="agent", status="running", created_at=now_ms(), + trainer_node_id="t1", graph=GraphDocument(version=1, nodes=[], edges=[]), + ) + run_store.write_run_record(running) + run_index.upsert_run(running) + run_index.touch_heartbeat(running.run_id, now_ms()) + assert client.delete(f"/api/runs/{running.run_id}").status_code == 409 + assert client.delete(f"/api/runs/{rid}").status_code == 200 + assert run_store.read_run_record(rid) is None + resp = client.delete("/api/runs", params={"status": "failed"}) + assert resp.status_code == 200 and resp.json()["deleted"] == 0 + + +def test_bulk_delete_by_group() -> None: + client = TestClient(app) + _seed_terminal("completed", group="g2") + _seed_terminal("aborted", group="g2") + resp = client.delete("/api/runs", params={"group_id": "g2"}) + assert resp.json()["deleted"] == 2 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest comfy_research/tests/test_runs_api.py -v` +Expected: FAIL — 404s on every `/api/runs` route + +- [ ] **Step 3: Write the implementation** + +```python +# comfy_research/api/runs.py +"""Run store API: async submit, query, metrics, abort, delete. Files are truth.""" +from __future__ import annotations + +import shutil + +from fastapi import APIRouter, Header, HTTPException, Request + +from comfy_research.engine.runs import run_index, run_store +from comfy_research.engine.runs.run_index import RunQuery +from comfy_research.engine.runs.run_worker import get_worker_pool +from comfy_research.schemas.run_record import TERMINAL_STATUSES +from comfy_research.schemas.train_request import TrainRequest + +router = APIRouter(prefix="/api/runs", tags=["runs"]) + +_HYPERPARAM_PREFIX = "hyperparam." + + +def _err(status: int, code: str, detail: str) -> HTTPException: + return HTTPException(status_code=status, detail={"code": code, "detail": detail}) + + +def _query_from_request(request: Request) -> RunQuery: + p = request.query_params + hyper = {k[len(_HYPERPARAM_PREFIX):]: v for k, v in p.items() + if k.startswith(_HYPERPARAM_PREFIX)} + ids = [s for s in (p.get("ids") or "").split(",") if s] or None + return RunQuery( + status=p.get("status"), origin=p.get("origin"), group_id=p.get("group_id"), + since_ms=float(p["since"]) if p.get("since") else None, + ids=ids, hyperparams=hyper, + order_by=p.get("order_by") or "-created_at", + limit=int(p.get("limit") or 100), cursor=p.get("cursor"), + ) + + +@router.post("", status_code=202) +def submit_run(body: TrainRequest, + idempotency_key: str | None = Header(default=None)) -> dict: + rec = get_worker_pool().submit(body, idempotency_key=idempotency_key) + return {"run_id": rec.run_id, "status": rec.status} + + +@router.get("") +def list_runs(request: Request) -> dict: + rows, next_cursor = run_index.query_runs(_query_from_request(request)) + return {"runs": rows, "next_cursor": next_cursor} + + +@router.get("/groups") +def list_groups() -> dict: + return {"groups": run_index.group_summary()} + + +@router.get("/{run_id}") +def get_run(run_id: str) -> dict: + rec = run_store.read_run_record(run_id) + if rec is None: + if (run_store.run_dir(run_id) / "run.json").exists(): + # file present but unparseable: surface it, don't 404 (spec: unreadable) + return {"run_id": run_id, "status": "unreadable", "summary": None} + raise _err(404, "run_not_found", f"No run {run_id!r}.") + _, series = run_store.load_series(run_id) + return {**rec.model_dump(mode="json"), "summary": run_store.summarize(series)} + + +@router.get("/{run_id}/metrics") +def get_run_metrics(run_id: str, downsample: int | None = None) -> dict: + if run_store.read_run_record(run_id) is None: + raise _err(404, "run_not_found", f"No run {run_id!r}.") + source, data = run_store.load_series(run_id) + downsampled = False + if downsample and downsample > 0: + out = {} + for key, series in data.items(): + if isinstance(series, list) and len(series) > downsample: + stride = -(-len(series) // downsample) + out[key] = series[::stride] + downsampled = True + else: + out[key] = series + data = out + return {"source": source, "data": data, "downsampled": downsampled} + + +@router.post("/{run_id}/abort") +def abort_run(run_id: str) -> dict: + ok = get_worker_pool().abort(run_id) + if not ok: + rec = run_store.read_run_record(run_id) + if rec is None: + raise _err(404, "run_not_found", f"No run {run_id!r}.") + return {"ok": False} + return {"ok": True} + + +def _delete_run_files(run_id: str) -> None: + shutil.rmtree(run_store.run_dir(run_id), ignore_errors=True) + run_index.delete_rows([run_id]) + + +_DELETABLE_STATUSES = TERMINAL_STATUSES | {"unreadable"} + + +@router.delete("/{run_id}") +def delete_run(run_id: str) -> dict: + rec = run_store.read_run_record(run_id) + if rec is None: + if (run_store.run_dir(run_id) / "run.json").exists(): + _delete_run_files(run_id) # unreadable: deletable, that's the point + return {"ok": True} + raise _err(404, "run_not_found", f"No run {run_id!r}.") + if rec.status not in _DELETABLE_STATUSES: + raise _err(409, "run_active", "Abort the run before deleting it.") + _delete_run_files(run_id) + return {"ok": True} + + +@router.delete("") +def bulk_delete(request: Request) -> dict: + q = _query_from_request(request) + if not any([q.status, q.origin, q.group_id, q.since_ms, q.ids, q.hyperparams]): + raise _err(400, "filter_required", + "Bulk delete requires at least one filter (status/origin/group_id/since/ids).") + # Collect ALL matching ids first (paginate to the end), then delete the deletable + # ones — deleting while paginating would shift the cursor and skip matches. + q.limit = 500 + to_delete: list[str] = [] + while True: + rows, cursor = run_index.query_runs(q) + to_delete.extend(r["run_id"] for r in rows if r["status"] in _DELETABLE_STATUSES) + if cursor is None: + break + q.cursor = cursor + for run_id in to_delete: + _delete_run_files(run_id) + return {"deleted": len(to_delete)} +``` + +In `comfy_research/main.py`: `from comfy_research.api.runs import router as runs_router` and `app.include_router(runs_router)` alongside the existing includes. Startup work goes into the EXISTING `_lifespan` function (`main.py:70` — read it first and add to it; do NOT run this at `create_app()` time, which executes on module import before tests can monkeypatch `COMFYRESEARCH_RUNS_DIR`): + +```python + # inside _lifespan, before the yield, alongside the existing startup work + try: + from comfy_research.engine.runs.run_index import reconcile_stale_running + reconcile_stale_running() + except Exception: # never block startup on store recovery + logging.getLogger(__name__).warning("run store reconciliation failed", exc_info=True) +``` + +Ensure `logging` is imported in `main.py` (add the import if absent). `TestClient(app)` runs the lifespan when used as a context manager; plain `TestClient(app).get(...)` calls skip it, which is fine — reconciliation is separately unit-tested in Task 3, so API tests here don't depend on lifespan execution. + +(Route-order note: `/groups` is declared before `/{run_id}` — FastAPI matches in declaration order. Also verify the SPA catch-all `GET /{full_path:path}` in `main.py` stays registered AFTER the new router — routers are added in `create_app()` before the catch-all, and the catch-all already 404s paths starting with `api`.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest comfy_research/tests/test_runs_api.py -v` +Expected: PASS (6 tests) + +- [ ] **Step 5: Check for a route-contract test** + +Run: `grep -rl "openapi()" comfy_research/tests/ tests/ 2>/dev/null` +If any test asserts on the OpenAPI path list, run it; if it pins exact routes, add the new `/api/runs*` paths to its expectation. If the grep finds nothing, skip this step. + +- [ ] **Step 6: Commit** + +```bash +git add comfy_research/api/runs.py comfy_research/main.py comfy_research/tests/test_runs_api.py +git commit -m "feat: add /api/runs router with async submit, query, metrics, and deletes" +``` + +--- + +### Task 9: GC / retention + +**Files:** +- Create: `comfy_research/engine/runs/run_gc.py` +- Modify: `comfy_research/main.py` (call after reconciliation), `comfy_research/engine/runs/run_worker.py` (`_execute` tail) +- Test: `comfy_research/tests/test_run_gc.py` + +**Interfaces:** +- Consumes: `run_store`, `run_index`, `RunQuery`, `TERMINAL_STATUSES`. +- Produces: `load_gc_config() -> dict` (reads optional `data/runs/config.json`; defaults `{"max_runs_agent": 2000, "max_age_days_agent": None, "worker_slots": 2}`), `run_gc_once() -> list[str]` (returns pruned run ids; only `origin="agent"`, terminal status, `finished_at` older than a 10-minute grace period; oldest-first beyond `max_runs_agent`; logs every pruned id). + +- [ ] **Step 1: Write the failing test** + +```python +# comfy_research/tests/test_run_gc.py +from __future__ import annotations + +from comfy_research.engine.runs import run_gc, run_index, run_store +from comfy_research.schemas.graph import GraphDocument +from comfy_research.schemas.run_record import RunRecord, new_run_id, now_ms + + +def _seed(status: str, origin: str, finished_ms_ago: float) -> str: + rec = RunRecord( + run_id=new_run_id(), origin=origin, status=status, + created_at=now_ms() - finished_ms_ago - 1000, + finished_at=(now_ms() - finished_ms_ago) if status != "running" else None, + trainer_node_id="t1", graph=GraphDocument(version=1, nodes=[], edges=[]), + ) + run_store.write_run_record(rec) + run_index.upsert_run(rec) + return rec.run_id + + +def test_gc_prunes_only_old_terminal_agent_runs(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + (tmp_path / "config.json").write_text('{"max_runs_agent": 2}', encoding="utf-8") + hour = 3_600_000.0 + keep_human = _seed("completed", "human", 10 * hour) + keep_recent = _seed("completed", "agent", 0.0) # inside grace period + keep_running = _seed("running", "agent", 5 * hour) + newest = _seed("completed", "agent", 1 * hour) + older = _seed("completed", "agent", 2 * hour) + oldest = _seed("completed", "agent", 3 * hour) + + pruned = run_gc.run_gc_once() + # agent rows newest-first by created_at: [keep_recent, newest, older, oldest, keep_running]; + # cap 2 keeps [keep_recent, newest]; over-cap = [older, oldest, keep_running], of which + # only terminal runs past the 10-minute grace period are prunable. + assert set(pruned) == {older, oldest} + for rid in (keep_human, keep_recent, keep_running, newest): + assert run_store.read_run_record(rid) is not None + for rid in pruned: + assert run_store.read_run_record(rid) is None + + +def test_gc_default_config_noop_under_cap(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + _seed("completed", "agent", 3_600_000.0) + assert run_gc.run_gc_once() == [] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest comfy_research/tests/test_run_gc.py -v` +Expected: FAIL with module not found + +- [ ] **Step 3: Write the implementation** + +```python +# comfy_research/engine/runs/run_gc.py +"""Single-owner retention GC for agent-origin runs. Runs only in the API server process.""" +from __future__ import annotations + +import json +import logging +import shutil + +from comfy_research.engine.runs import run_index, run_store +from comfy_research.engine.runs.run_index import RunQuery +from comfy_research.schemas.run_record import TERMINAL_STATUSES, now_ms + +logger = logging.getLogger(__name__) + +_GRACE_MS = 10 * 60 * 1000.0 +_DEFAULTS = {"max_runs_agent": 2000, "max_age_days_agent": None, "worker_slots": 2} + + +def load_gc_config() -> dict: + path = run_store.runs_root() / "config.json" + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + raw = {} + return {**_DEFAULTS, **{k: raw[k] for k in _DEFAULTS if k in raw}} + + +def run_gc_once() -> list[str]: + cfg = load_gc_config() + rows: list[dict] = [] + q = RunQuery(origin="agent", order_by="-created_at", limit=500) + while True: + page, cursor = run_index.query_runs(q) + rows.extend(page) + if cursor is None: + break + q.cursor = cursor + cutoff_ms = now_ms() - _GRACE_MS + max_age = cfg["max_age_days_agent"] + age_cutoff = now_ms() - max_age * 86_400_000.0 if max_age else None + + def prunable(r: dict) -> bool: + return (r["status"] in TERMINAL_STATUSES + and r["finished_at"] is not None and r["finished_at"] < cutoff_ms) + + pruned: list[str] = [] + over_cap = rows[cfg["max_runs_agent"]:] # rows are newest-first + for r in over_cap: + if prunable(r): + pruned.append(r["run_id"]) + if age_cutoff is not None: + for r in rows[: cfg["max_runs_agent"]]: + if prunable(r) and r["finished_at"] < age_cutoff: + pruned.append(r["run_id"]) + for run_id in pruned: + shutil.rmtree(run_store.run_dir(run_id), ignore_errors=True) + run_index.delete_rows(pruned) + for run_id in pruned: + logger.info("run GC pruned %s", run_id) + return pruned +``` + +Wire-up: in `main.py`, call `run_gc_once()` inside the same guarded `try` as reconciliation. In `run_worker.py`, at the very end of `_execute` (after the trainer lock releases), add: + +```python + try: + from comfy_research.engine.runs.run_gc import run_gc_once + run_gc_once() + except Exception: + logger.warning("post-run GC failed", exc_info=True) +``` + +Also in `run_worker.py`, size the pool from config: in `get_worker_pool()`, `RunWorkerPool(slots=load_gc_config()["worker_slots"])`. + +- [ ] **Step 4: Run tests** + +Run: `python -m pytest comfy_research/tests/test_run_gc.py comfy_research/tests/test_run_worker.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add comfy_research/engine/runs/run_gc.py comfy_research/engine/runs/run_worker.py \ + comfy_research/main.py comfy_research/tests/test_run_gc.py +git commit -m "feat: add single-owner run GC with agent retention policy" +``` + +--- + +### Task 10: Frontend runs API client + rail panel (list) + +**Files:** +- Create: `frontend/src/graph/runsApi.ts`, `frontend/src/components/RunsPanel.tsx` +- Modify: `frontend/src/components/railTypes.ts`, `frontend/src/components/LeftNavRail.tsx`, `frontend/src/components/ResearchCanvas.tsx` (~line 5996-6048 slot block), `frontend/src/index.css` (panel styles), `frontend/src/graph/__tests__/leftNavRail.v1.seam.test.tsx` (labels list) +- Test: `frontend/src/graph/__tests__/runsPanel.test.tsx` + +**Interfaces:** +- Consumes: `GET /api/runs`, `GET /api/runs/{id}/metrics` (Task 8 shapes); `SweepVizLinePlot` + `PlotSeries` (Task 11 uses them; this task only lists). +- Produces: + - `runsApi.ts`: `type RunRow = { run_id: string; group_id: string | null; origin: string; status: string; created_at: number; finished_at: number | null; trainer_node_id: string; final_loss: number | null; final_test_loss: number | null; steps_completed: number | null; duration_seconds: number | null; hyperparams: Record }`, `fetchRuns(params?: Record): Promise<{ runs: RunRow[]; next_cursor: string | null }>`, `fetchRunMetrics(runId: string): Promise<{ source: string; data: Record }>`, `fetchRunRecord(runId: string): Promise<{ graph: GraphDocument } & Record>`, `deleteRun(runId: string): Promise`. + - `RunsPanel.tsx`: `export function RunsPanel(props: { onOpenRunGraph: (runId: string) => void })` — fetches on mount, polls every 3 s while any run has status `queued`/`running`, groups rows by `group_id` (ungrouped first), collapses `origin === "sweep" || origin === "agent"` groups by default, checkbox per row feeding selection state (used by Task 11), delete button per terminal row. + - Rail: `RailPrimarySection` gains `"runs"`; `LeftNavRail` `primaryItems` gains `{ id: "runs", label: "Runs" }` + a `case "runs"` icon; `ResearchCanvas` renders `{railSection === "runs" ?
: null}` (conditional-mount form, matching Templates). `openRunGraphInNewProject` is a stub in this task (`console.warn`); Task 11 implements it. + +- [ ] **Step 1: Write the failing test** + +```tsx +// frontend/src/graph/__tests__/runsPanel.test.tsx +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { RunsPanel } from "../../components/RunsPanel"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const RUNS = { + runs: [ + { run_id: "run-aaa", group_id: null, origin: "human", status: "completed", + created_at: 1, finished_at: 2, trainer_node_id: "t1", final_loss: 0.25, + final_test_loss: null, steps_completed: 4, duration_seconds: 1.5, hyperparams: {} }, + { run_id: "run-bbb", group_id: "sweep-1", origin: "sweep", status: "failed", + created_at: 3, finished_at: 4, trainer_node_id: "t1", final_loss: null, + final_test_loss: null, steps_completed: 0, duration_seconds: null, hyperparams: {} }, + ], + next_cursor: null, +}; + +let host: HTMLDivElement; + +beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: true, + json: async () => RUNS, + })) as unknown as typeof fetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + host.remove(); +}); + +test("renders run rows grouped, sweep group collapsed by default", async () => { + const root = createRoot(host); + await act(async () => { + root.render( {}} />); + }); + await act(async () => { await Promise.resolve(); }); + expect(host.textContent).toContain("run-aaa"); + expect(host.textContent).toContain("sweep-1"); + // collapsed group hides its member row until expanded + expect(host.textContent).not.toContain("run-bbb"); + const toggle = host.querySelector('[data-testid="run-group-toggle-sweep-1"]'); + expect(toggle).not.toBeNull(); + await act(async () => { toggle!.click(); }); + expect(host.textContent).toContain("run-bbb"); + await act(async () => { root.unmount(); }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/graph/__tests__/runsPanel.test.tsx` +Expected: FAIL — module `../../components/RunsPanel` not found + +- [ ] **Step 3: Implement** + +`frontend/src/graph/runsApi.ts`: + +```ts +import type { GraphDocument } from "../types/graph"; + +export type RunRow = { + run_id: string; + group_id: string | null; + origin: string; + status: string; + created_at: number; + finished_at: number | null; + trainer_node_id: string; + final_loss: number | null; + final_test_loss: number | null; + steps_completed: number | null; + duration_seconds: number | null; + hyperparams: Record; +}; + +async function readJson(res: Response): Promise { + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || res.statusText); + } + return (await res.json()) as T; +} + +export async function fetchRuns( + params?: Record, +): Promise<{ runs: RunRow[]; next_cursor: string | null }> { + const qs = params ? `?${new URLSearchParams(params)}` : ""; + return readJson(await fetch(`/api/runs${qs}`, { cache: "no-store" })); +} + +export async function fetchRunMetrics( + runId: string, +): Promise<{ source: string; data: Record }> { + return readJson(await fetch(`/api/runs/${runId}/metrics`, { cache: "no-store" })); +} + +export async function fetchRunRecord( + runId: string, +): Promise<{ graph: GraphDocument } & Record> { + return readJson(await fetch(`/api/runs/${runId}`, { cache: "no-store" })); +} + +export async function deleteRun(runId: string): Promise { + await readJson(await fetch(`/api/runs/${runId}`, { method: "DELETE" })); +} +``` + +`frontend/src/components/RunsPanel.tsx` (structure; reuse `cr-nodes-panel*` chrome): + +```tsx +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { deleteRun, fetchRuns, type RunRow } from "../graph/runsApi"; + +const ACTIVE_STATUSES = new Set(["queued", "running"]); +const POLL_MS = 3000; +const TERMINAL_DELETABLE = new Set(["completed", "failed", "aborted", "paused", "crashed"]); + +type RunGroup = { key: string; label: string; rows: RunRow[]; collapsedByDefault: boolean }; + +function groupRuns(rows: RunRow[]): RunGroup[] { + const ungrouped: RunRow[] = []; + const byGroup = new Map(); + for (const row of rows) { + if (row.group_id) { + const list = byGroup.get(row.group_id) ?? []; + list.push(row); + byGroup.set(row.group_id, list); + } else { + ungrouped.push(row); + } + } + const groups: RunGroup[] = []; + if (ungrouped.length) { + groups.push({ key: "", label: "Runs", rows: ungrouped, collapsedByDefault: false }); + } + for (const [key, groupRows] of byGroup) { + const collapsed = groupRows.every((r) => r.origin === "sweep" || r.origin === "agent"); + groups.push({ key, label: key, rows: groupRows, collapsedByDefault: collapsed }); + } + return groups; +} + +export function RunsPanel({ + onOpenRunGraph, + selectedRunIds, + onToggleSelect, +}: { + onOpenRunGraph: (runId: string) => void; + selectedRunIds?: ReadonlySet; + onToggleSelect?: (runId: string) => void; +}) { + const [rows, setRows] = useState([]); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState>({}); + + const load = useCallback(() => { + fetchRuns({ limit: "200" }) + .then((body) => { setRows(body.runs); setError(null); }) + .catch((e: Error) => setError(e.message)); + }, []); + + useEffect(() => { + load(); + }, [load]); + + const anyActive = useMemo(() => rows.some((r) => ACTIVE_STATUSES.has(r.status)), [rows]); + useEffect(() => { + if (!anyActive) return; + const id = window.setInterval(load, POLL_MS); + return () => window.clearInterval(id); + }, [anyActive, load]); + + const groups = useMemo(() => groupRuns(rows), [rows]); + + return ( + + ); +} +``` + +Rail wiring: +- `railTypes.ts`: `export type RailPrimarySection = "nodes" | "observables" | "templates" | "runs";` +- `LeftNavRail.tsx`: append `{ id: "runs", label: "Runs" }` to `primaryItems`; add `case "runs":` to `RailIcon` returning a simple inline SVG (e.g. three horizontal bars of differing length, `stroke="currentColor"`). +- `ResearchCanvas.tsx` (slot block ~line 5996): add after the observables slot: + +```tsx + {railSection === "runs" ? ( +
+ { console.warn("open run", runId); }} /> +
+ ) : null} +``` + +- `leftNavRail.v1.seam.test.tsx`: update `V1_RAIL_LABELS` to `["Nodes", "Observables", "Templates", "Runs"]` (this list is an explicit-decision seam; this plan is that decision). +- `index.css`: add `.cr-runs-panel__row { display: flex; gap: 6px; align-items: center; }`, `.cr-runs-panel__status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cr-text-4); }`, `[data-status="completed"].cr-runs-panel__status-dot { background: var(--cr-chart-2); }`, `[data-status="failed"].cr-runs-panel__status-dot, [data-status="crashed"].cr-runs-panel__status-dot { background: var(--cr-chart-1); }`, `.cr-runs-panel__error { color: var(--cr-chart-1); }` — token vars only, no raw hex. + +- [ ] **Step 4: Run tests + hex ratchet** + +Run: `cd frontend && npx vitest run src/graph/__tests__/runsPanel.test.tsx src/graph/__tests__/leftNavRail.v1.seam.test.tsx && npm run verify:css-tokens` +Expected: PASS, ratchet clean + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/graph/runsApi.ts frontend/src/components/RunsPanel.tsx \ + frontend/src/components/railTypes.ts frontend/src/components/LeftNavRail.tsx \ + frontend/src/components/ResearchCanvas.tsx frontend/src/index.css \ + frontend/src/graph/__tests__/runsPanel.test.tsx \ + frontend/src/graph/__tests__/leftNavRail.v1.seam.test.tsx +git commit -m "feat: add Runs rail panel with grouped run list" +``` + +--- + +### Task 11: Overlay comparison chart + open-run-graph + +**Files:** +- Create: `frontend/src/graph/runCompareOverlay.ts` +- Modify: `frontend/src/components/RunsPanel.tsx` (selection state + chart), `frontend/src/components/ResearchCanvas.tsx` (`openRunGraphInNewProject`) +- Test: `frontend/src/graph/__tests__/runCompareOverlay.test.ts` + +**Interfaces:** +- Consumes: `PlotSeries`/`PlotPoint` + `SERIES_COLORS` from `frontend/src/graph/sweepVizPlot.ts`; `SweepVizLinePlot` from `frontend/src/components/nodes/SweepVizLinePlot.tsx`; `fetchRunMetrics`, `fetchRunRecord` (Task 10); `sanitizeLoadedGraph`, `newProjectId`-style project creation as done by `openSavedGraphInNewProject` (`ResearchCanvas.tsx:5761`). +- Produces: + - `buildRunCompareSeries(inputs: Array<{ runId: string; label: string; data: Record }>): PlotSeries[]` — one series per run from `loss_history` vs `step_ticks` (falls back to index when `step_ticks` shorter), plus a dashed series per run from `test_loss_history` when non-empty; colors cycle `SERIES_COLORS`; test series reuse their run's color with `strokeDasharray: "4 3"`. + - `openRunGraphInNewProject(runId: string)` in `ResearchCanvas` — fetches the record, `sanitizeLoadedGraph(record.graph)`, creates a new project tab titled `Run {runId}` (same shape as `openSavedGraphInNewProject` minus `librarySource`), activates it. + - `RunsPanel` moves selection to internal state: drop the Task-10 `selectedRunIds`/`onToggleSelect` props and hold `const [selectedRunIds, setSelectedRunIds] = useState>(new Set())` inside the component (update the Task-10 test if its props usage breaks). When ≥1 selected, fetch metrics per selected run (cache in a `useRef(Map)`), render ``. + +- [ ] **Step 1: Write the failing test** + +```ts +// frontend/src/graph/__tests__/runCompareOverlay.test.ts +import { expect, test } from "vitest"; + +import { buildRunCompareSeries } from "../runCompareOverlay"; + +test("one solid series per run, dashed test series, distinct colors", () => { + const series = buildRunCompareSeries([ + { runId: "run-a", label: "run-a", + data: { loss_history: [1, 0.5], test_loss_history: [2, 1], step_ticks: [0, 1] } }, + { runId: "run-b", label: "run-b", + data: { loss_history: [3, 2, 1], test_loss_history: [], step_ticks: [0, 1, 2] } }, + ]); + expect(series.map((s) => s.id)).toEqual(["run-a", "run-a:test", "run-b"]); + expect(series[0].points.map((p) => p.y)).toEqual([1, 0.5]); + expect(series[0].points.map((p) => p.x)).toEqual([0, 1]); + expect(series[1].strokeDasharray).toBeTruthy(); + expect(series[1].color).toBe(series[0].color); + expect(series[2].color).not.toBe(series[0].color); +}); + +test("falls back to index when step_ticks missing", () => { + const [s] = buildRunCompareSeries([ + { runId: "run-c", label: "run-c", data: { loss_history: [5, 4], step_ticks: [] } }, + ]); + expect(s.points.map((p) => p.x)).toEqual([0, 1]); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/graph/__tests__/runCompareOverlay.test.ts` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement** + +```ts +// frontend/src/graph/runCompareOverlay.ts +import { SERIES_COLORS, type PlotPoint, type PlotSeries } from "./sweepVizPlot"; + +type RunSeriesInput = { runId: string; label: string; data: Record }; + +function toPoints(ys: number[], xs: number[], rowId: string): PlotPoint[] { + return ys + .map((y, i) => ({ x: xs[i] ?? i, xDisplay: String(xs[i] ?? i), y, rowId })) + .filter((p) => Number.isFinite(p.y)); +} + +export function buildRunCompareSeries(inputs: RunSeriesInput[]): PlotSeries[] { + const series: PlotSeries[] = []; + inputs.forEach((input, i) => { + const color = SERIES_COLORS[i % SERIES_COLORS.length]; + const steps = input.data.step_ticks ?? []; + const loss = input.data.loss_history ?? []; + series.push({ id: input.runId, label: input.label, color, + points: toPoints(loss, steps, input.runId) }); + const test = input.data.test_loss_history ?? []; + if (test.length) { + series.push({ id: `${input.runId}:test`, label: `${input.label} (test)`, color, + strokeDasharray: "4 3", points: toPoints(test, steps, input.runId) }); + } + }); + return series; +} +``` + +`SERIES_COLORS` in `sweepVizPlot.ts` (line ~109) is currently a NON-exported local constant — add `export` to it as part of this task (verify no name collision first; it's an array of `var(--cr-chart-N)` strings). In `RunsPanel.tsx`, add the selection + chart described in Interfaces. In `ResearchCanvas.tsx`, implement `openRunGraphInNewProject` as a `useCallback` next to `openSavedGraphInNewProject` (line ~5761), reusing its project-creation shape: + +```tsx + const openRunGraphInNewProject = useCallback((runId: string) => { + void fetchRunRecord(runId).then((record) => { + const { nodes, edges } = sanitizeLoadedGraph(record.graph); + const id = newProjectId(); + setProjects((list) => [ + ...list, + { + id, + title: formatProjectTabTitle(`Run ${runId}`), + canvas: { + id: newCanvasId(), + title: `Run ${runId}`, + nodes, + edges, + savedViewport: record.graph.viewport ?? null, + viewportApplyNonce: 1, + dirty: false, + }, + }, + ]); + setActiveProjectId(id); + }); + }, []); +``` + +Copy the exact project/canvas object shape from `openSavedGraphInNewProject` at implementation time (helper names `newProjectId` / `newCanvasId` / `formatProjectTabTitle` must match what that function actually uses; omit `librarySource`). Replace the Task 10 `console.warn` stub with this callback. + +- [ ] **Step 4: Run all frontend tests + build gate** + +Run: `cd frontend && npx vitest run && npm run verify:css-tokens` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/graph/runCompareOverlay.ts frontend/src/components/RunsPanel.tsx \ + frontend/src/components/ResearchCanvas.tsx frontend/src/graph/__tests__/runCompareOverlay.test.ts +git commit -m "feat: add run comparison overlay chart and open-run-graph action" +``` + +--- + +### Task 12: Docs + +**Files:** +- Create: `docs/en/reference/runs-api.md` +- Modify: `docs/en/reference/data-contracts.md` (add "Run store" section), `docs/en/reference/training-api.md` (mention `run_registered` event + `run_origin`/`run_group_id`/`run_parent_id` fields) + +**Interfaces:** documentation of Task 8's routes and Task 2's on-disk layout, exactly as implemented. + +- [ ] **Step 1: Write `docs/en/reference/runs-api.md`** + +Cover, with the same table style as `training-api.md`: the async submit contract (202, `Idempotency-Key`, remote-GPU 400), every `/api/runs*` route with query params, the status lifecycle (`queued → running → completed | failed | aborted | paused | crashed`, plus `crashed` reconciliation and `unreadable`), the metrics authority rule (`source` field), the error envelope `{"code", "detail"}`, cursor pagination, bulk-delete filter requirement, and the GC defaults (`data/runs/config.json`). Include one curl example: submit `minimal_cpu_train_request`-shaped JSON, poll `GET /api/runs/{id}`, fetch `GET /api/runs/{id}/metrics`. + +- [ ] **Step 2: Update `data-contracts.md`** + +Add a "Run store (`data/runs/`)" section: directory layout (`run.json` / `metrics.ndjson` / `results.json`), files-are-truth + rebuildable `index.db` (and `scripts` note: rebuild happens automatically on corruption; never hand-edit `index.db`), what is never persisted (checkpoints, PNGs, embedding/attention histories), `paused` = terminal-in-store with resume-as-new-run via `parent_id`. + +- [ ] **Step 3: Build docs** + +Run: `python scripts/build_docs.py` +Expected: build succeeds; fix any Sphinx warnings introduced by the new page (add it to the reference toctree — mirror how `training-api.md` is listed). + +- [ ] **Step 4: Full test sweep** + +Run: `python -m pytest comfy_research/tests -x -q -m "not repro and not slow" && cd frontend && npx vitest run` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add docs/en/reference/runs-api.md docs/en/reference/data-contracts.md docs/en/reference/training-api.md +git commit -m "docs: document the run store API and on-disk contracts" +``` diff --git a/docs/superpowers/plans/2026-08-17-runs-panel-redesign.md b/docs/superpowers/plans/2026-08-17-runs-panel-redesign.md new file mode 100644 index 0000000..4ae6de7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-runs-panel-redesign.md @@ -0,0 +1,661 @@ +# Runs Panel Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restyle the Runs panel per the approved spec: trainer-title-first two-layer rows with native-checkbox selection semantics, always-visible muted action buttons, group summaries, themed header/empty state, and a panel-owned compare legend with a full-width chart. + +**Architecture:** One small backend addition (`trainer_title` on RunRecord + SQLite index with an idempotent ALTER migration in `_connect`), then pure frontend rework of `RunsPanel.tsx` + scoped CSS. Spec: `docs/superpowers/specs/2026-08-17-runs-panel-redesign.md`. + +**Tech Stack:** Python/Pydantic/sqlite3 backend; React + TS, plain token-based CSS, vitest/jsdom frontend. + +## Global Constraints + +- Backend tests: `/opt/homebrew/Caskroom/miniconda/base/envs/comfyresearch/bin/python -m pytest -v` from the worktree root (`/Users/abroham/Desktop/ComfyResearch/.claude/worktrees/run-store`). Frontend: `cd frontend && npx vitest run `; CSS gate `npm run verify:css-tokens` (no raw hex — `var(--cr-*)` only). +- The index is never a truth source; index write failures log, never raise. The new column migration must be idempotent and run before any SELECT can touch the column. +- Shared chart CSS (`.cr-tviz-chart`, `.cr-tviz-chart-wrap`) is NEVER modified globally; all stretching lives under `.cr-runs-panel__compare`. `SweepVizLinePlot` already has `viewBox="0 0 232 122"` — do not modify the component. +- Selection stays a real `` (visually hidden, focusable); action buttons are siblings OUTSIDE the label, always visible, with aria-labels. +- Status colors/tokens: completed `--cr-chart-2`; failed/crashed `--cr-chart-1`; running `--cr-accent` + pulse; queued `--cr-text-4`; aborted/paused/unreadable hollow (border `--cr-text-4`, transparent fill). +- Existing behavior preserved except: open-config-graph moves to the ↗ icon button. +- `Date.now()` is fine in app code; formatter tests must inject `nowMs` explicitly (no fake timers needed). + +## File Structure + +| File | Responsibility | +| --- | --- | +| `comfy_research/schemas/run_record.py` (modify) | `trainer_title` field | +| `comfy_research/engine/runs/run_writer.py` (modify) | extract `instanceTitle` in `build_run_record` | +| `comfy_research/engine/runs/run_index.py` (modify) | column + `_COLUMNS` + upsert + ALTER migration | +| `frontend/src/graph/runFormat.ts` (new) | `formatRelativeTime`, `formatDuration`, `runDisplayTitle` | +| `frontend/src/graph/runsApi.ts` (modify) | `RunRow.trainer_title` | +| `frontend/src/graph/runCompareOverlay.ts` (modify) | shared `runSeriesColor`, `buildRunLegend` | +| `frontend/src/components/RunsPanel.tsx` (modify) | full rework | +| `frontend/src/index.css` (modify) | new `cr-runs-panel__*` styles | +| Tests | `comfy_research/tests/test_run_index.py`, `test_run_record.py` (or writer test file), `test_runs_api.py`; `frontend/src/graph/__tests__/runFormat.test.ts` (new), `runsPanel.test.tsx`, `runCompareOverlay.test.ts` | + +--- + +### Task 1: Backend `trainer_title` + index migration + +**Files:** +- Modify: `comfy_research/schemas/run_record.py` (RunRecord), `comfy_research/engine/runs/run_writer.py:88-109` (`build_run_record`), `comfy_research/engine/runs/run_index.py` (`_COLUMNS`, `_SCHEMA`, `_connect`, `upsert_run`) +- Test: `comfy_research/tests/test_run_index.py`, `comfy_research/tests/test_run_writer.py`, `comfy_research/tests/test_runs_api.py` + +**Interfaces:** +- Consumes: existing `RunRecord`, `build_run_record`, `_connect`/`upsert_run`/`query_runs`. +- Produces: `RunRecord.trainer_title: str = ""`; `query_runs` rows include `"trainer_title"`; opening a legacy index auto-migrates. + +- [ ] **Step 1: Write the failing tests** + +Append to `comfy_research/tests/test_run_index.py`: + +```python +def test_legacy_index_without_trainer_title_auto_migrates(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("COMFYRESEARCH_RUNS_DIR", str(tmp_path)) + # Build an index with the PRE-trainer_title schema and one row, bypassing _connect. + import sqlite3 + + tmp_path.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(tmp_path / "index.db") + conn.executescript( + """ + CREATE TABLE runs ( + run_id TEXT PRIMARY KEY, group_id TEXT, parent_id TEXT, + origin TEXT NOT NULL, status TEXT NOT NULL, + created_at REAL NOT NULL, started_at REAL, finished_at REAL, + last_heartbeat_at REAL, trainer_node_id TEXT NOT NULL, + device TEXT DEFAULT '', error_detail TEXT DEFAULT '', + hyperparams_json TEXT NOT NULL DEFAULT '{}', + final_loss REAL, final_test_loss REAL, best_test_loss REAL, + steps_completed INTEGER, duration_seconds REAL + ); + """ + ) + conn.execute( + "INSERT INTO runs (run_id, origin, status, created_at, trainer_node_id) " + "VALUES ('run-legacy000001', 'human', 'completed', 1.0, 't1')" + ) + conn.commit() + conn.close() + + rows, _ = run_index.query_runs(RunQuery()) + assert [r["run_id"] for r in rows] == ["run-legacy000001"] + assert rows[0]["trainer_title"] == "" + + +def test_trainer_title_round_trips_through_index(tmp_path, monkeypatch) -> None: + _seed(monkeypatch, tmp_path) + rec = _record(status="completed").model_copy(update={"trainer_title": "Grokking Trainer"}) + run_store.write_run_record(rec) + run_index.upsert_run(rec) + rows, _ = run_index.query_runs(RunQuery(ids=[rec.run_id])) + assert rows[0]["trainer_title"] == "Grokking Trainer" +``` + +Append to `comfy_research/tests/test_run_writer.py`: + +```python +def test_build_run_record_extracts_trainer_title() -> None: + from comfy_research.engine.runs.run_writer import build_run_record + from comfy_research.schemas.train_request import TrainRequest + from comfy_research.tests.train_test_fixtures import minimal_cpu_train_request + + body = minimal_cpu_train_request() + for n in body["nodes"]: + if n["id"] == body["trainer_node_id"]: + n["data"] = {**n["data"], "instanceTitle": "My Trainer"} + rec = build_run_record(TrainRequest.model_validate(body)) + assert rec.trainer_title == "My Trainer" + + rec2 = build_run_record(TrainRequest.model_validate(minimal_cpu_train_request())) + assert rec2.trainer_title == "" + + body3 = minimal_cpu_train_request() + for n in body3["nodes"]: + if n["id"] == body3["trainer_node_id"]: + n["data"] = {**n["data"], "instanceTitle": None} + rec3 = build_run_record(TrainRequest.model_validate(body3)) + assert rec3.trainer_title == "" # None must not become the string "None" +``` + +In `comfy_research/tests/test_runs_api.py`, extend `_seed_terminal` (add `trainer_title="Seeded Trainer"` to the `RunRecord(...)` construction) and add to `test_list_filters_and_hyperparam` one assertion: `assert body["runs"][0]["trainer_title"] == "Seeded Trainer"` (place it after the existing status-filter assertion, where `body` holds the completed-run response). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `/opt/homebrew/Caskroom/miniconda/base/envs/comfyresearch/bin/python -m pytest comfy_research/tests/test_run_index.py -k "trainer_title or legacy_index" comfy_research/tests/test_run_writer.py -k trainer_title -v` +Expected: FAIL (ValidationError on unknown field / missing column / missing key) + +- [ ] **Step 3: Implement** + +`run_record.py` — add to `RunRecord` after `device`: + +```python + trainer_title: str = "" +``` + +`run_writer.py` `build_run_record` — after the `device = ...` line (NOT raw `str()`: that would coerce `None` to `"None"` and non-strings to reprs; only genuine strings count, trimmed): + +```python + raw_title = (trainer.data or {}).get("instanceTitle") if trainer else None + trainer_title = raw_title.strip() if isinstance(raw_title, str) else "" +``` + +and pass `trainer_title=trainer_title,` in the `RunRecord(...)` call (after `device=device`). + +`run_index.py`: +- `_COLUMNS`: insert `"trainer_title"` immediately after `"device"`. +- `_SCHEMA` CREATE TABLE: add `trainer_title TEXT DEFAULT '',` after the `device` line. +- `upsert_run` values tuple: insert `rec.trainer_title` at the position matching `_COLUMNS` (after `rec.device`). +- `_connect()` — after `conn.executescript(_SCHEMA)`: + +```python + cols = {row[1] for row in conn.execute("PRAGMA table_info(runs)")} + if "trainer_title" not in cols: + conn.execute("ALTER TABLE runs ADD COLUMN trainer_title TEXT DEFAULT ''") +``` + +(`_row_to_dict` needs no change — it zips `_COLUMNS`.) + +- [ ] **Step 4: Run the full backend run-store suites** + +Run: `/opt/homebrew/Caskroom/miniconda/base/envs/comfyresearch/bin/python -m pytest comfy_research/tests/test_run_index.py comfy_research/tests/test_run_writer.py comfy_research/tests/test_runs_api.py comfy_research/tests/test_run_gc.py comfy_research/tests/test_train_run_capture.py -v` +Expected: PASS (the ALTER must not break rebuild/reconcile/GC paths) + +- [ ] **Step 5: Commit** + +```bash +git add comfy_research/schemas/run_record.py comfy_research/engine/runs/run_writer.py \ + comfy_research/engine/runs/run_index.py comfy_research/tests/test_run_index.py \ + comfy_research/tests/test_run_writer.py comfy_research/tests/test_runs_api.py +git commit -m "feat: persist trainer title on runs with idempotent index migration" +``` + +--- + +### Task 2: Formatters + RunRow type + +**Files:** +- Create: `frontend/src/graph/runFormat.ts` +- Modify: `frontend/src/graph/runsApi.ts` (`RunRow`) +- Test: `frontend/src/graph/__tests__/runFormat.test.ts` + +**Interfaces:** +- Produces: `formatRelativeTime(ms: number, nowMs?: number): string`, `formatDuration(seconds: number | null): string | null`, `runDisplayTitle(row: { trainer_title?: string; run_id: string }): string`; `RunRow` gains `trainer_title: string`. + +- [ ] **Step 1: Write the failing test** + +```ts +// frontend/src/graph/__tests__/runFormat.test.ts +import { expect, test } from "vitest"; + +import { formatDuration, formatRelativeTime, runDisplayTitle } from "../runFormat"; + +const NOW = 1_700_000_000_000; + +test("formatRelativeTime buckets", () => { + expect(formatRelativeTime(NOW - 10_000, NOW)).toBe("just now"); + expect(formatRelativeTime(NOW - 3 * 60_000, NOW)).toBe("3m ago"); + expect(formatRelativeTime(NOW - 2 * 3_600_000, NOW)).toBe("2h ago"); + expect(formatRelativeTime(NOW - 30 * 3_600_000, NOW)).toBe("yesterday"); + // beyond 48h: a short date, not a relative phrase + const old = formatRelativeTime(NOW - 10 * 86_400_000, NOW); + expect(old).not.toMatch(/ago|now|yesterday/); + expect(old.length).toBeGreaterThan(0); +}); + +test("formatDuration", () => { + expect(formatDuration(null)).toBeNull(); + expect(formatDuration(12.4)).toBe("12s"); + expect(formatDuration(252)).toBe("4m12s"); + expect(formatDuration(3780)).toBe("1h03m"); +}); + +test("runDisplayTitle falls back to short hash", () => { + expect(runDisplayTitle({ trainer_title: "Grokking", run_id: "run-b00420904839" })).toBe("Grokking"); + expect(runDisplayTitle({ trainer_title: "", run_id: "run-b00420904839" })).toBe("run-b004…"); + expect(runDisplayTitle({ run_id: "run-b00420904839" })).toBe("run-b004…"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/graph/__tests__/runFormat.test.ts` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement** + +```ts +// frontend/src/graph/runFormat.ts +export function formatRelativeTime(ms: number, nowMs: number = Date.now()): string { + const delta = Math.max(0, nowMs - ms); + if (delta < 45_000) return "just now"; + if (delta < 3_600_000) return `${Math.round(delta / 60_000)}m ago`; + if (delta < 86_400_000) return `${Math.round(delta / 3_600_000)}h ago`; + if (delta < 2 * 86_400_000) return "yesterday"; + return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +export function formatDuration(seconds: number | null): string | null { + if (seconds == null || !Number.isFinite(seconds)) return null; + const s = Math.round(seconds); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`; + return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`; +} + +export function runDisplayTitle(row: { trainer_title?: string; run_id: string }): string { + const title = (row.trainer_title ?? "").trim(); + return title ? title : `${row.run_id.slice(0, 8)}…`; +} +``` + +In `runsApi.ts`, add `trainer_title: string;` to `RunRow` (after `trainer_node_id`). + +- [ ] **Step 4: Run tests** + +Run: `cd frontend && npx vitest run src/graph/__tests__/runFormat.test.ts && npx tsc --noEmit -p . 2>&1 | grep -c "runsApi\|runFormat" || true` +Expected: tests PASS; zero tsc errors mentioning the touched files (baseline has pre-existing errors elsewhere). + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/graph/runFormat.ts frontend/src/graph/runsApi.ts \ + frontend/src/graph/__tests__/runFormat.test.ts +git commit -m "feat: add run display formatters and trainer_title row field" +``` + +--- + +### Task 3: Panel list rework (rows, groups, header, empty state, CSS) + +**Files:** +- Modify: `frontend/src/components/RunsPanel.tsx` (list portion; compare section untouched until Task 4), `frontend/src/index.css` +- Test: `frontend/src/graph/__tests__/runsPanel.test.tsx` + +**Interfaces:** +- Consumes: Task 2's `formatRelativeTime`/`formatDuration`/`runDisplayTitle`, `RunRow.trainer_title`. +- Produces: new row/group/header/empty DOM structure and `cr-runs-panel__*` classes that Task 4's compare section slots under. Selection state/logic (`selectedRunIds`, `onToggleSelect`, polling, delete handler, metrics effect) unchanged. + +- [ ] **Step 1: Update the tests to the new contract (they will fail against current code)** + +Rewrite the assertions in `frontend/src/graph/__tests__/runsPanel.test.tsx` (keep the existing fetch-stub scaffolding and the delete-failure + live-metrics tests, updating only selectors): + +- Fixture rows gain `trainer_title` (`"Trainer A"` for run-aaa, `""` for run-bbb). +- Grouped/collapse test: unchanged toggle testid; additionally assert the group toggle text contains `1 run` (count) for sweep-1. +- New/updated assertions in the render test: + - `host.textContent` contains `"Trainer A"`; does NOT contain the full `run-aaa…` id as visible text (fallback rows show `run-bbb0…` style short hash — with the fixture id `run-bbb` shorter than 8 chars, expect `run-bbb…`? No: `slice(0,8)` of `"run-bbb"` is `"run-bbb"`, so expect `"run-bbb…"`. Use realistic 16-char ids in fixtures instead: `run-aaa000000001`, `run-bbb000000001`, and expect `"run-bbb0…"`). + - Each row has `input[type=checkbox]` with `aria-label` `Compare Trainer A` (title-based, not id-based). + - Row has a `button[aria-label="Open graph for Trainer A"]` and (terminal row) `button[aria-label="Delete Trainer A"]`. + - Clicking the checkbox sets the row container's `data-selected="true"`. + - Sub-line renders relative time text (assert contains `"ago"` or `"just now"` using a `created_at` near `Date.now()` in fixtures). + - Empty-state test: stub fetch returning `{runs: [], next_cursor: null}`, assert text `"Run a training to see it here."`. + - Header: `button[aria-label="Refresh"]` exists; old text-"Refresh" button gone. +- Update the delete-failure test's clicked selector to `button[aria-label="Delete Trainer A"]`; update the live-metrics test's checkbox selectors to the new aria-labels. + +- [ ] **Step 2: Run to verify failures** + +Run: `cd frontend && npx vitest run src/graph/__tests__/runsPanel.test.tsx` +Expected: FAIL on the new selectors + +- [ ] **Step 3: Implement the JSX rework** + +In `RunsPanel.tsx`, replace the header, group-toggle, and row JSX (state/effects untouched). Imports add: `formatDuration, formatRelativeTime, runDisplayTitle` from `../graph/runFormat`. + +Header: + +```tsx +
+ Runs + +
+``` + +Empty state (after the error paragraph): + +```tsx + {!error && rows.length === 0 ? ( +

Run a training to see it here.

+ ) : null} +``` + +Group summary helper (module scope): + +```tsx +function groupSummary(rows: RunRow[]): string { + const finite = rows.map((r) => r.final_loss).filter((v): v is number => v != null && Number.isFinite(v)); + const count = `${rows.length} run${rows.length === 1 ? "" : "s"}`; + if (!finite.length) return count; + return `${count} · best ${Math.min(...finite).toPrecision(3)}`; +} +``` + +Group toggle: + +```tsx + +``` + +Row: + +```tsx + const title = runDisplayTitle(row); + const selected = selectedRunIds.has(row.run_id); + const duration = formatDuration(row.duration_seconds); + return ( +
+ + + + {TERMINAL_DELETABLE.has(row.status) ? ( + + ) : null} + +
+ ); +``` + +(The row map callback changes from an expression to a block body returning this JSX. The old status text `{row.status}` is removed — status is conveyed by the dot color + title attribute; keep `data-status` for styling/tests.) + +- [ ] **Step 4: CSS** + +Check whether `.cr-visually-hidden` exists in `frontend/src/index.css` (`grep -n "cr-visually-hidden" frontend/src/index.css`); if absent, add the standard clip pattern. Then add (token vars only, adjust the existing `.cr-runs-panel__row`/`__status-dot`/`__error` rules rather than duplicating them): + +```css +.cr-visually-hidden { + position: absolute; width: 1px; height: 1px; margin: -1px; + padding: 0; border: 0; clip: rect(0 0 0 0); overflow: hidden; white-space: nowrap; +} +.cr-runs-panel__refresh, +.cr-runs-panel__icon-btn { + background: none; border: none; cursor: pointer; + color: var(--cr-text-4); font-size: 13px; padding: 2px 4px; border-radius: 4px; +} +.cr-runs-panel__refresh:hover, .cr-runs-panel__refresh:focus-visible, +.cr-runs-panel__icon-btn:hover, .cr-runs-panel__icon-btn:focus-visible { + color: var(--cr-text-2); background: var(--cr-surface-2); +} +.cr-runs-panel__row { + display: flex; align-items: center; gap: 4px; + padding: 5px 8px 5px 6px; border-left: 2px solid transparent; border-radius: 4px; +} +.cr-runs-panel__row[data-selected] { + border-left-color: var(--cr-accent); + background: var(--cr-surface-2); +} +.cr-runs-panel__row-select { + display: flex; align-items: center; gap: 7px; flex: 1; min-width: 0; cursor: pointer; +} +.cr-runs-panel__row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; } +.cr-runs-panel__row-main { display: flex; align-items: baseline; gap: 8px; } +.cr-runs-panel__title { + flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 13px; font-weight: 500; color: var(--cr-text-1); +} +.cr-runs-panel__loss { + font-family: var(--cr-font-mono, monospace); font-size: 12px; color: var(--cr-text-2); +} +.cr-runs-panel__row-sub { font-size: 11px; color: var(--cr-text-3); } +.cr-runs-panel__actions { display: flex; align-items: center; gap: 0; } +.cr-runs-panel__status-dot { + width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--cr-text-4); +} +.cr-runs-panel__status-dot[data-status="completed"] { background: var(--cr-chart-2); } +.cr-runs-panel__status-dot[data-status="failed"], +.cr-runs-panel__status-dot[data-status="crashed"] { background: var(--cr-chart-1); } +.cr-runs-panel__status-dot[data-status="running"] { + background: var(--cr-accent); animation: cr-runs-pulse 1.4s ease-in-out infinite; +} +.cr-runs-panel__status-dot[data-status="aborted"], +.cr-runs-panel__status-dot[data-status="paused"], +.cr-runs-panel__status-dot[data-status="unreadable"] { + background: transparent; border: 1.5px solid var(--cr-text-4); +} +@keyframes cr-runs-pulse { 50% { opacity: 0.35; } } +.cr-runs-panel__group-toggle { + display: flex; align-items: center; gap: 6px; width: 100%; + background: none; border: none; cursor: pointer; padding: 6px 8px; + font-size: 12px; color: var(--cr-text-2); text-align: left; +} +.cr-runs-panel__chevron { transition: transform 120ms ease; color: var(--cr-text-4); } +.cr-runs-panel__chevron[data-expanded="true"] { transform: rotate(90deg); } +.cr-runs-panel__group-label { font-weight: 500; } +.cr-runs-panel__group-summary { color: var(--cr-text-3); font-size: 11px; } +.cr-runs-panel__group .cr-runs-panel__row { margin-left: 10px; } +.cr-runs-panel__empty { + padding: 24px 12px; text-align: center; font-size: 12px; color: var(--cr-text-3); +} +``` + +Also update the EXISTING `.cr-runs-panel__error` rule (index.css ~line 1586, currently color-only) to the spec's small-text restyle: + +```css +.cr-runs-panel__error { + color: var(--cr-chart-1); font-size: 12px; padding: 6px 10px; margin: 0; +} +``` + +Check `--cr-font-mono` exists in `tokens.css` (`grep -n "font-mono" frontend/src/tokens.css`); if not, use the plain `monospace` fallback form shown (the `var(--x, fallback)` keyword `monospace` is not a hex and passes the ratchet). + +- [ ] **Step 5: Run tests + gates** + +Run: `cd frontend && npx vitest run src/graph/__tests__/runsPanel.test.tsx && npm run verify:css-tokens` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/components/RunsPanel.tsx frontend/src/index.css \ + frontend/src/graph/__tests__/runsPanel.test.tsx +git commit -m "feat: redesign runs panel rows, groups, header, and empty state" +``` + +--- + +### Task 4: Compare section (legend model, scoped chart stretch, clear button) + +**Files:** +- Modify: `frontend/src/graph/runCompareOverlay.ts`, `frontend/src/components/RunsPanel.tsx` (compare section), `frontend/src/index.css` +- Test: `frontend/src/graph/__tests__/runCompareOverlay.test.ts`, `frontend/src/graph/__tests__/runsPanel.test.tsx` + +**Interfaces:** +- Consumes: Task 3's DOM/classes; `runDisplayTitle`. +- Produces: `runSeriesColor(index: number): string`; `type RunLegendEntry = { runId: string; title: string; color: string; hasTest: boolean }`; `buildRunLegend(inputs: RunSeriesInput[]): RunLegendEntry[]` where `RunSeriesInput` is the existing exported-or-local input type (export it). + +- [ ] **Step 1: Write the failing tests** + +Append to `runCompareOverlay.test.ts`: + +```ts +import { buildRunCompareSeries, buildRunLegend, runSeriesColor } from "../runCompareOverlay"; + +test("buildRunLegend: one entry per run, colors match series, hasTest flags", () => { + const inputs = [ + { runId: "run-a", label: "Trainer A", + data: { loss_history: [1], test_loss_history: [2], step_ticks: [0] } }, + { runId: "run-b", label: "Trainer B", + data: { loss_history: [3], test_loss_history: [], step_ticks: [0] } }, + ]; + const legend = buildRunLegend(inputs); + expect(legend).toEqual([ + { runId: "run-a", title: "Trainer A", color: runSeriesColor(0), hasTest: true }, + { runId: "run-b", title: "Trainer B", color: runSeriesColor(1), hasTest: false }, + ]); + const series = buildRunCompareSeries(inputs); + expect(series[0]!.color).toBe(legend[0]!.color); + expect(series[2]!.color).toBe(legend[1]!.color); +}); +``` + +In `runsPanel.test.tsx`, extend the metrics/selection test (or add one): select two runs (metrics stubbed), assert: a heading containing `Compare (2)`; a `button` with text `Clear` that empties selection (after click, `data-selected` rows gone and compare section unmounted); legend list items equal to the two trainer titles (one entry each, no `(test)` suffix text). + +- [ ] **Step 2: Run to verify failures** + +Run: `cd frontend && npx vitest run src/graph/__tests__/runCompareOverlay.test.ts src/graph/__tests__/runsPanel.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Implement** + +`runCompareOverlay.ts`: + +```ts +export type RunSeriesInput = { runId: string; label: string; data: Record }; + +export function runSeriesColor(index: number): string { + return SERIES_COLORS[index % SERIES_COLORS.length]!; +} + +export type RunLegendEntry = { runId: string; title: string; color: string; hasTest: boolean }; + +export function buildRunLegend(inputs: RunSeriesInput[]): RunLegendEntry[] { + return inputs.map((input, i) => ({ + runId: input.runId, + title: input.label, + color: runSeriesColor(i), + hasTest: (input.data.test_loss_history ?? []).length > 0, + })); +} +``` + +and switch `buildRunCompareSeries`'s color line to `const color = runSeriesColor(i);`. + +`RunsPanel.tsx` compare section: keep a `compareInputs` state alongside `compareSeries` (or derive both from one state holding the fetched `{runId, label, data}` list — implementation's choice; the plan's shape: store the inputs array in state, derive `series = buildRunCompareSeries(inputs)` and `legend = buildRunLegend(inputs)` via `useMemo`). The metrics effect's `.then` now builds inputs with `label: runDisplayTitle(row)` (look up the row by id from `rows`; fallback `runDisplayTitle({run_id: runId})` when the row vanished). Replace the section JSX: + +```tsx + {selectedRunIds.size > 0 ? ( +
+
+ Compare ({selectedRunIds.size}) + +
+ +
    + {legend.map((entry) => ( +
  • + + {entry.hasTest ? ( + + ) : null} + {entry.title} +
  • + ))} +
+
+ ) : null} +``` + +CSS additions: + +```css +.cr-runs-panel__compare { + margin-top: 8px; padding: 8px; border-top: 1px solid var(--cr-hairline); +} +.cr-runs-panel__compare .cr-tviz-chart { width: 100%; height: auto; } +.cr-runs-panel__compare-header { + display: flex; align-items: center; justify-content: space-between; + font-size: 12px; font-weight: 500; color: var(--cr-text-2); margin-bottom: 6px; +} +.cr-runs-panel__legend { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; } +.cr-runs-panel__legend-entry { + display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--cr-text-2); +} +``` + +- [ ] **Step 4: Full frontend sweep + gates** + +Run: `cd frontend && npx vitest run && npm run verify:css-tokens && npm run build 2>&1 | tail -2` +Expected: all tests PASS, ratchet holds, build succeeds + +- [ ] **Step 5: Backend regression sweep** + +Run: `/opt/homebrew/Caskroom/miniconda/base/envs/comfyresearch/bin/python -m pytest comfy_research/tests -q -m "not repro and not slow" --deselect "comfy_research/tests/test_information_bottleneck_reproduction.py::InformationBottleneckReproductionTests::test_single_logit_full_batch_updates_match_reference_trajectory"` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/graph/runCompareOverlay.ts frontend/src/components/RunsPanel.tsx \ + frontend/src/index.css frontend/src/graph/__tests__/runCompareOverlay.test.ts \ + frontend/src/graph/__tests__/runsPanel.test.tsx +git commit -m "feat: panel-owned compare legend with full-width chart and clear action" +``` diff --git a/docs/superpowers/specs/2026-08-15-run-store-design.md b/docs/superpowers/specs/2026-08-15-run-store-design.md new file mode 100644 index 0000000..63789c3 --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-run-store-design.md @@ -0,0 +1,155 @@ +# Run Store Design (rev 2, post-Codex-review) + +Status: DRAFT rev 2 — revised per Codex review 2026-08-15; pending user approval. + +## Problem + +ComfyResearch persists no training results server-side. `POST /api/train` streams NDJSON and keeps nothing; the browser stashes `lossHistory` / `memoryCheckpoint_b64` back into node `data`, which lands in `workspace.json` or graph-library templates (hence the 21 MB library bloat). Results are lost if the stream drops, there is no run history, no cross-run comparison, and no way for an agent to submit a run and query results later. + +## Goals (v1) + +1. Every training run is persisted server-side with a `run_id`: metadata, hyperparameters, scalar metric histories, terminal status. +2. Agent-first async API: submit returns `run_id` immediately; execution is detached from any HTTP stream; poll/list/filter/bulk-delete. +3. A runs panel in the canvas UI: run list with grouping, curve-overlay comparison. +4. Supports agent mass-rollouts: thousands of runs, concurrent creation, O(1) write path, grouping, batch GC, crash recovery. + +## Non-goals (v1) + +- Checkpoint/blob externalization (phase 2; streams pass `checkpoint_b64` through unchanged; the store never persists it). +- Backfill of existing embedded results. +- wandb/MLflow export. Multi-user/auth. +- CRL runs (`post_train`'s CRL generator variant): explicitly excluded from capture in v1; classic trainer runs only. + +## Architecture + +**Files are the source of truth; SQLite is a rebuildable read index. Persistence happens exclusively in the API-host server process.** + +### Capture point: engine layer, not the HTTP generator + +`RunWriter` hooks where trainer events are actually produced/consumed — wrapping `iter_trainer_events` / `iter_trainer_events_from_context` consumption sites: + +- Single run (`/api/train` local path): the stream generator drives a `RunWriter` alongside yielding. +- **Sweep / coordinate descent**: `train_sweep.py` and `train_coordinate_descent.py` consume inner-run events internally and only emit wrapper events upward; each **inner run gets its own `RunWriter`** (own `run_id`, `group_id` = sweep session id) at that consumption site. An API-level tee cannot see these — this is why capture lives in the engine. +- **Remote runs**: the API-host side consumes the SSH event stream (`remote/ssh.py` iterator); `RunWriter` attaches there. The remote CLI process never writes the store (it runs on the remote host's checkout — writing there would create a split-brain store). +- **Async submit** (below): the worker drives `RunWriter` directly. + +### Execution detached from the response stream (agent path) + +Starlette cancels streaming work on client disconnect, and the remote iterator kills the SSH process in `finally` — so a tee alone cannot make runs durable. Therefore: + +- **`POST /api/runs` (async submit)**: validates via the existing prepare pipeline (400 on bad graph), registers the run, enqueues onto a server-owned worker pool (thread pool, configurable cap, default = 2; excess runs queue as `queued`), returns **202 + `run_id`** synchronously. Optional `Idempotency-Key` header: same key returns the existing run instead of double-submitting. Execution and persistence are independent of any client connection. +- **`POST /api/train` (existing, browser path)**: unchanged semantics — stream drives execution, disconnect aborts (matches "closing the tab cancels"). A `RunWriter` tees what is emitted, and abort-on-disconnect finalizes the run as `aborted` with partial metrics. First event added: `{"type": "run_registered", "run_id"}`. + +Agents use submit+poll; the browser keeps its live stream. Both paths converge on the same engine-level capture. + +### On-disk layout (source of truth) + +``` +data/runs/ + {run_id}/ # "run-" + uuid4().hex[:12] + run.json # RunRecord (atomic write, rewritten on status change) + metrics.ndjson # append-only per-step scalar DELTA rows + results.json # terminal snapshot: full histories (atomic write, once) +``` + +### Metrics scheme (delta-based; cumulative-history trap addressed) + +The trainer emits **cumulative** histories on every `metrics` event; appending raw payloads would be O(n²) in storage. Instead `RunWriter` keeps the last-seen length per series and appends **only new entries** as scalar rows to `metrics.ndjson`. It also retains the latest cumulative snapshot in memory so that: + +- baseline step-0 values recorded before the first `metrics` event are captured from the first emission's full history (deltas start from index 0); +- on `aborted` / `error` — which emit no histories — `results.json` is still written from the last-seen snapshot; +- on `complete`, `results.json` is written from the terminal event's authoritative histories. + +**Authority rule**: if `results.json` exists it is the authoritative series; `metrics.ndjson` is authoritative only for runs that died without one (crash). `GET /api/runs/{id}/metrics` serves exactly one source and reports which (`"source": "results" | "ndjson"`), never a merge. + +Stripped from all persisted data in v1: `checkpoint_b64`, `plot_png_base64`, embedding/attention histories. + +### RunRecord (`run.json`) — new `comfy_research/schemas/run_record.py` + +``` +run_id, schema_version: 1 +group_id: str | None # sweep session / rollout batch +parent_id: str | None # originating run (e.g. resume-continuation) or agent session +origin: "human" | "agent" | "sweep" +status: "queued" | "running" | "completed" | "failed" | "aborted" | "paused" | "crashed" | "unreadable" +created_at, started_at, finished_at: float | None (unix ms) +trainer_node_id, device, error_detail +graph: GraphDocument # config-only snapshot (results/UI blobs stripped via existing tier logic) +hyperparams: dict[str, scalar] # flattened via generated param models +``` + +`TrainRequest` gains optional `run_origin` / `run_group_id` / `run_parent_id` (default `origin="human"`) — existing clients unaffected. + +**`paused` semantics (v1)**: terminal in the store. Resume still happens through the existing client-held `checkpoint_b64` flow; the continuation is a **new run** with `parent_id` = the paused run. The store does not promise resume-by-run_id (that requires phase-2 checkpoint persistence). + +### Crash recovery + +- The index carries `last_heartbeat_at`, updated (index-only, coalesced to ≥1 s intervals) on each event batch. +- On server startup — and lazily whenever a `running` row's heartbeat is older than 60 s — the run is reconciled to `crashed` (file rewritten, index updated). No permanently stuck `running` rows. +- Interrupted `metrics.ndjson` tail lines are tolerated on read: a non-parsing final line is dropped with a logged warning. +- A `run.json` that fails to parse surfaces as `status="unreadable"` in listings (id + path) — a deliberate break from the graph-library silent-skip pattern. + +### SQLite index (`data/runs/index.db`) — write discipline + +- WAL mode, **per-process connection**, `busy_timeout=5000`, transactions kept to single upserts. WAL serializes writers; it does not make many-writer contention free — so index writes are limited to: registration, coalesced heartbeat, terminal upsert (with summary columns). Per-step metrics never touch the index. +- Columns: RunRecord scalars + `hyperparams_json` + summaries materialized at terminal state: `final_loss`, `final_test_loss`, `best_test_loss`, `steps_completed`, `duration_seconds`. +- Not a truth source: validated on startup; missing/corrupt → rebuild by scanning `data/runs/*/run.json` (also `scripts/rebuild_run_index.py`). Write path is file-first; index failure logs a warning, never fails the run. + +## HTTP API (new router `api/runs.py`) + +| Method | Path | Notes | +| --- | --- | --- | +| POST | `/api/runs` | async submit: body = TrainRequest (+ origin/group/parent), 202 + `{run_id}`; optional `Idempotency-Key` | +| GET | `/api/runs` | filters `status`, `origin`, `group_id`, `since`, `hyperparam.`, `ids=` (bulk fetch); `order_by` (e.g. `-final_loss`); **cursor pagination** (`cursor`/`limit`, default 100) | +| GET | `/api/runs/{run_id}` | RunRecord + summary | +| GET | `/api/runs/{run_id}/metrics` | single-source series (see authority rule), optional `downsample=N` | +| GET | `/api/runs/groups` | per-group status counts + best summaries | +| POST | `/api/runs/{run_id}/abort` | for submitted runs (delegates to existing train control) | +| DELETE | `/api/runs/{run_id}` | refuses `running`/`queued` | +| DELETE | `/api/runs` | bulk; requires ≥1 filter; terminal-state runs only | + +Errors use a structured envelope `{"code", "detail"}` (new pattern; existing endpoints migrate later). + +## Runs panel UI (frontend) + +- Left-rail "Runs" panel: table (status dot, group/trainer title, created, duration, final loss), grouped by `group_id`, `origin=agent` groups collapsed by default. +- Multi-select → overlay loss/test-loss curves (reuse existing curve components), legend annotated with differing hyperparams. +- Row actions: open stored config graph on canvas; delete. +- Polls `GET /api/runs` while any run is non-terminal. + +## GC / retention + +- **Single-owner**: only the API server process runs GC (startup + periodic), never the CLI. +- Eligible: terminal-state runs only, older than a 10-minute grace period. +- Default policy: keep all `origin=human`; for `origin=agent`, prune oldest beyond `max_runs_agent` (default 2000). Every prune logs what was dropped — no silent caps. +- Agent-facing cleanup: bulk `DELETE /api/runs?group_id=...`. +- Optional `data/runs/config.json`: `max_runs_agent`, `max_age_days_agent`, worker-pool size. + +## Testing + +- Unit: delta extraction from cumulative emissions (incl. step-0 baseline, abort-without-histories); authority rule; hyperparam flattening; index rebuild equivalence (write N, delete index, rebuild, compare query results). +- API: submit→poll→terminal RunRecord correctness; idempotency key; filters + cursor pagination; bulk-delete guards; unreadable surfacing; abort of queued/running submitted runs. +- Failure: kill worker mid-run → heartbeat reconciliation to `crashed`; truncated ndjson tail tolerated. +- Concurrency: worker pool at cap + interleaved queries against WAL index. +- Sweep: inner runs each captured with `group_id`; wrapper events unchanged. +- Frontend: grouped list from fixture; overlay selection. + +## Phasing + +- v1: everything above. +- Phase 2: checkpoint/blob persistence in a local content-addressed artifact store (reuse `remote/execution_artifacts.py` design) → real resume-by-run_id; backfill; structured-error migration of existing endpoints; CRL run capture; optional wandb exporter. + +## Codex review disposition (2026-08-15) + +| # | Finding | Disposition | +| --- | --- | --- | +| 1 | Critical: disconnect cancels training; tee ≠ durability | Added detached execution: `POST /api/runs` worker pool; `/api/train` keeps browser semantics (disconnect = abort, finalized as `aborted`) | +| 2 | High: CLI RunWriter = split-brain on remote host | Persistence is API-host-only; capture attaches to the SSH stream consumer | +| 3 | High: API-level tee misses sweep/CD inner runs | Capture moved to engine-level consumption sites; inner runs get own RunWriter | +| 4 | High: cumulative histories → O(n²) or loss | Delta-based writer + snapshot; explicit single-source authority rule | +| 5 | High: no crash recovery | Heartbeat + `crashed` status + startup/lazy reconciliation | +| 6 | Medium: WAL over-claimed | Write discipline: per-process conn, busy_timeout, start/heartbeat/terminal writes only | +| 7 | Medium: GC races live writers | Single-owner GC, terminal-only, grace period | +| 8 | Medium: stream-first API | 202 submit, idempotency key, cursor pagination, `ids=` bulk fetch, `queued` state | +| 9 | Medium: `paused` misleading without checkpoints | Documented terminal-in-store; resume = new run with `parent_id`; real resume in phase 2 | +| 10 | Medium: CRL variant unaddressed | Explicitly excluded from v1, listed in phase 2 | diff --git a/docs/superpowers/specs/2026-08-17-runs-panel-redesign.md b/docs/superpowers/specs/2026-08-17-runs-panel-redesign.md new file mode 100644 index 0000000..40eb0ae --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-runs-panel-redesign.md @@ -0,0 +1,62 @@ +# Runs Panel Redesign + +Status: DRAFT rev 2 — revised per Codex review 2026-08-17; pending user approval. +Builds on: the run-store branch (`worktree-run-store`), specifically `frontend/src/components/RunsPanel.tsx`, `frontend/src/graph/runsApi.ts`, `frontend/src/graph/runCompareOverlay.ts`, and the `/api/runs` backend. + +## Problem + +The current Runs panel is functionally complete but visually raw: native blue checkboxes, run hashes in bordered pills as the primary label, oversized typography with no hierarchy, a 232px chart squeezed by large margins, and a legend that lists every run twice (train + test). It does not match the app's polished warm-paper aesthetic, and run hashes carry no information for the user. + +## Scope + +Visual refresh + information-hierarchy rework of the existing panel. Behavior preserved: selection→overlay, 3s polling while active, delete, group collapse. One deliberate behavior change: open-config-graph moves from name-click to a dedicated always-visible icon button (name area becomes the selection toggle). One small backend addition (trainer title + index migration). Explicitly excluded: run detail expansion, search/filter, pagination/load-more, in-progress step display. + +## 1. Data (the only backend change) + +- `RunRecord` gains `trainer_title: str = ""`, extracted in `build_run_record` from the trainer node's `data.instanceTitle` (empty string when absent). +- `run_index` gains a `trainer_title` column in `_COLUMNS`/schema/upsert/row-dict lockstep, returned in `GET /api/runs` rows. +- **Schema-drift migration (required)**: an existing healthy `index.db` without the column will NOT trigger the repair/rebuild hook (it only fires on corruption or row-undercount), and `query_runs`'s SELECT would fail outright. `_connect()` therefore checks `PRAGMA table_info(runs)` after `executescript(_SCHEMA)` and issues `ALTER TABLE runs ADD COLUMN trainer_title TEXT DEFAULT ''` when absent (cheap, idempotent, single check per connection; pre-existing rows show the hash fallback until their next upsert or a rebuild). +- Existing runs persisted before this change have no `trainer_title`; they display the hash fallback. Acceptable — no backfill. +- Frontend formats relative time from `created_at` ("just now", "3m ago", "2h ago", "yesterday", then a short date) and duration from `duration_seconds` ("12s", "4m12s", "1h03m"). No new backend fields for these. +- Group summary line (`8 runs · best 3.2e-3`) is computed client-side from already-fetched rows (min of finite `final_loss` in the group). No backend change. Known limitation, accepted: the panel fetches `limit=200`, so for histories beyond 200 runs the summary reflects loaded rows only (pagination stays out of scope). + +## 2. Run row + +Two-layer row, full-row click toggles selection: + +- **Main line**: status dot · trainer title (medium weight, truncated with ellipsis) · right-aligned final loss in monospace (`toPrecision(3)`; em-dash placeholder when null). +- **Sub line**: `3m ago · 4m12s` in `--cr-text-3`, smaller size. Duration segment omitted when `duration_seconds` is null. +- **Title fallback**: when `trainer_title` is empty, show the run id truncated to `run-b004…` (first 8 chars of the hash + ellipsis). +- **Actions always visible, low emphasis** (hover-only reveal is unusable on touch and hurts discoverability): open-graph (↗) and delete (✕, terminal statuses only, existing handler + error surfacing) render as small muted icon buttons (`--cr-text-4`) at the row's right edge, intensifying to `--cr-text-2` on hover/focus. Both carry `aria-label`s ("Open graph for ", "Delete <title>"). +- **Selection semantics stay a real checkbox** (a plain button role cannot convey checked state to assistive tech and cannot legally nest the action buttons): each row keeps an `<input type="checkbox">`, visually hidden but focusable (`.cr-visually-hidden` pattern), wrapped in a `<label>` that covers the title + sub-line area (NOT the action buttons, which are sibling `<button>`s outside the label). Native semantics give multiselect, keyboard toggling, and `:checked` state for free; the selected style (2px left accent bar `--cr-accent` + subtle tinted background) is driven by `:has(:checked)` on the row (or a `data-selected` attribute mirror — implementation's choice, same visual). +- **Open-config-graph** moves from "click the run name" to the always-visible ↗ icon button, since the name area now toggles selection. This is the one deliberate behavior change from the current panel. +- Full `run_id` shown via the row's `title` attribute on hover. +- **Status dot colors** (theme tokens only): completed `--cr-chart-2` (green family), failed/crashed `--cr-chart-1` (red family), running = accent-colored dot with a CSS pulse animation, queued `--cr-text-4`, aborted/paused = hollow dot (border, transparent fill), unreadable `--cr-text-4` hollow. + +## 3. Group rows, header, empty state + +- Group row: `▸ sweep-lr · 8 runs · best 3.2e-3` — chevron rotates on expand, children indented. Collapse default unchanged (all-sweep/agent groups collapsed). +- Panel header matches Nodes/Templates chrome (`cr-nodes-panel__header`): title "Runs" + a small icon-only refresh button (↻) right-aligned (aria-label "Refresh"), replacing the default-styled text button. +- Empty state: centered muted text in the panel body ("Run a training to see it here." — final copy to match the app's existing empty-hint tone, cf. `SavedGraphLibraryPanel` `emptyHint`). +- Error banner keeps existing behavior, restyled to token colors (small text, `--cr-chart-1`). + +## 4. Compare section + +- Distinct section under the list, separated by a hairline (`--cr-hairline`): header `Compare (2)` + a small "Clear" text button that empties the selection. +- Chart: `SweepVizLinePlot` currently emits `width={232} height={122}` on the shared `.cr-tviz-chart` class with no viewBox and no responsive CSS. Changes: (a) add `viewBox="0 0 232 122"` to the component's `<svg>` (no visual change anywhere — fixed width/height still governs default rendering); (b) scale ONLY inside a runs-panel-scoped wrapper: `.cr-runs-panel__compare .cr-tviz-chart { width: 100%; height: auto; }`. The shared `.cr-tviz-chart` rules are never modified globally — other plots are untouched. Stretched SVG text scales up proportionally; at panel widths (~300-500px, ≤2.2×) this is acceptable and avoids a chart-internals rewrite (explicitly out of scope). +- Legend: built and owned by the panel (it CANNOT come from `SweepVizLinePlot`'s `legendSummary`/`showLegendList` props, and `buildRunCompareSeries` deliberately emits separate train/test series). The panel derives a legend model from the selected runs — `{runId, title (fallback short hash), color, hasTest}` — reusing the same color-assignment order as `buildRunCompareSeries` (extract the color-cycling into a shared helper so the two cannot drift). One entry per run: solid line sample + (when `hasTest`) a dashed sample in the same color, then the title in `--cr-text-2`. +- `showLegendList` is set to false; the `N series` count line is removed. + +## Non-goals + +Run detail expansion, search, pagination, progress display, chart-internals redesign, changing selection/polling/delete semantics. + +## Error handling + +Unchanged from current panel (fetch errors → error banner; delete errors → banner via existing catch). New formatting helpers are pure functions; null/undefined inputs render placeholders, never throw. + +## Testing + +- Backend: `trainer_title` extraction in `build_run_record` (with/without instanceTitle); index column round-trip in `query_runs` rows; rebuild path includes the column; **legacy-schema migration test**: create an index with the OLD schema (no `trainer_title`), insert a row, then open via `_connect()` and assert `query_runs` succeeds and returns `trainer_title: ""` (this is the most dangerous regression and must be covered). +- Frontend (vitest/jsdom): relative-time and duration formatters (unit); row renders title/fallback/sub-line; checkbox toggle updates selection state; action buttons present with correct `aria-label`s; legend renders one entry per run with title text; empty state renders. Existing `runsPanel.test.tsx` selectors hard-code the old checkbox markup — they will be updated, not weakened. Honest gap, accepted: jsdom cannot verify CSS hover-emphasis or the stretched-SVG rendering; those are verified manually in the browser. +- CSS gate: `npm run verify:css-tokens` must stay green (token vars only). diff --git a/frontend/src/components/LeftNavRail.tsx b/frontend/src/components/LeftNavRail.tsx index 40dd615..47d5018 100644 --- a/frontend/src/components/LeftNavRail.tsx +++ b/frontend/src/components/LeftNavRail.tsx @@ -12,6 +12,7 @@ const primaryItems: RailItem[] = [ { id: "nodes", label: "Nodes" }, { id: "observables", label: "Observables" }, { id: "templates", label: "Templates" }, + { id: "runs", label: "Runs" }, ]; const THEME_OPTIONS: { id: CrTheme; label: string }[] = [ @@ -44,6 +45,12 @@ function RailIcon({ name }: { name: string }) { <path d="M9 7h6M9 11h6M9 15h4" strokeLinecap="round" /> </svg> ); + case "runs": + return ( + <svg viewBox="0 0 24 24" aria-hidden {...common}> + <path d="M5 6h9M5 12h14M5 18h6" strokeLinecap="round" /> + </svg> + ); case "settings": return ( <svg viewBox="0 0 24 24" aria-hidden {...common}> diff --git a/frontend/src/components/ResearchCanvas.tsx b/frontend/src/components/ResearchCanvas.tsx index 2aa9758..e28cc42 100644 --- a/frontend/src/components/ResearchCanvas.tsx +++ b/frontend/src/components/ResearchCanvas.tsx @@ -262,6 +262,8 @@ import { import { ObservablePanel } from "../observables/ObservablePanel"; import { isObservableModelNodeType } from "../observables/modelNodeTypes"; import { researchNodeTypes } from "./nodeTypes"; +import { RunsPanel } from "./RunsPanel"; +import { fetchRunRecord } from "../graph/runsApi"; import { migrateObservableVizNodeTypes } from "../graph/observableVizVariant"; import { beginLibraryNodeDrag, @@ -5799,6 +5801,34 @@ export function ResearchCanvas() { [], ); + const openRunGraphInNewProject = useCallback((runId: string) => { + void fetchRunRecord(runId).then((record) => { + const { nodes, edges } = sanitizeLoadedGraph(record.graph); + const id = newProjectId(); + const canvasId = newProjectId(); + setProjects((list) => [ + ...list, + { + id, + title: `Run ${runId}`, + canvas: { + id: canvasId, + title: `Run ${runId}`, + nodes, + edges, + savedViewport: record.graph.viewport ?? null, + viewportApplyNonce: 1, + dirty: false, + }, + }, + ]); + setActiveProjectId(id); + setNotice(null); + }).catch((e: unknown) => { + setNotice(e instanceof Error ? e.message : "Could not open run graph."); + }); + }, []); + useEffect(() => { if (typeof window === "undefined") return; const params = new URLSearchParams(window.location.search); @@ -6041,6 +6071,11 @@ export function ResearchCanvas() { /> </div> ) : null} + {railSection === "runs" ? ( + <div className="cr-workbench-rail-panel__slot"> + <RunsPanel onOpenRunGraph={openRunGraphInNewProject} /> + </div> + ) : null} </div> <div className={`cr-workbench-rail-panel__grip${railPanelGripDragging ? " cr-workbench-rail-panel__grip--active" : ""}`} diff --git a/frontend/src/components/RunsPanel.tsx b/frontend/src/components/RunsPanel.tsx new file mode 100644 index 0000000..41374a5 --- /dev/null +++ b/frontend/src/components/RunsPanel.tsx @@ -0,0 +1,287 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { buildRunCompareSeries, buildRunLegend, type RunSeriesInput } from "../graph/runCompareOverlay"; +import { disambiguateTitles, formatDuration, formatRelativeTime, runDisplayTitle } from "../graph/runFormat"; +import { deleteRun, fetchRunMetrics, fetchRuns, type RunRow } from "../graph/runsApi"; +import { SweepVizLinePlot } from "./nodes/SweepVizLinePlot"; + +const ACTIVE_STATUSES = new Set(["queued", "running"]); +const POLL_MS = 3000; +const TERMINAL_DELETABLE = new Set(["completed", "failed", "aborted", "paused", "crashed"]); + +type RunGroup = { key: string; label: string; rows: RunRow[]; collapsedByDefault: boolean }; + +function groupRuns(rows: RunRow[]): RunGroup[] { + const ungrouped: RunRow[] = []; + const byGroup = new Map<string, RunRow[]>(); + for (const row of rows) { + if (row.group_id) { + const list = byGroup.get(row.group_id) ?? []; + list.push(row); + byGroup.set(row.group_id, list); + } else { + ungrouped.push(row); + } + } + const groups: RunGroup[] = []; + if (ungrouped.length) { + groups.push({ key: "", label: "Runs", rows: ungrouped, collapsedByDefault: false }); + } + for (const [key, groupRows] of byGroup) { + const collapsed = groupRows.every((r) => r.origin === "sweep" || r.origin === "agent"); + groups.push({ key, label: key, rows: groupRows, collapsedByDefault: collapsed }); + } + return groups; +} + +function groupSummary(rows: RunRow[]): string { + const finite = rows.map((r) => r.final_loss).filter((v): v is number => v != null && Number.isFinite(v)); + const count = `${rows.length} run${rows.length === 1 ? "" : "s"}`; + if (!finite.length) return count; + return `${count} · best ${Math.min(...finite).toPrecision(3)}`; +} + +export function RunsPanel({ + onOpenRunGraph, +}: { + onOpenRunGraph: (runId: string) => void; +}) { + const [rows, setRows] = useState<RunRow[]>([]); + const [error, setError] = useState<string | null>(null); + const [loaded, setLoaded] = useState(false); + const [expanded, setExpanded] = useState<Record<string, boolean>>({}); + const [selectedRunIds, setSelectedRunIds] = useState<Set<string>>(new Set()); + const [compareInputs, setCompareInputs] = useState<RunSeriesInput[]>([]); + const metricsCacheRef = useRef(new Map<string, Record<string, number[]>>()); + + const compareSeries = useMemo(() => buildRunCompareSeries(compareInputs), [compareInputs]); + const legend = useMemo(() => buildRunLegend(compareInputs), [compareInputs]); + // All runs of a sweep share one instanceTitle; disambiguate duplicates at + // the display layer so aria-labels/legend don't collapse into identical + // entries. Computed once per loaded-rows change, reused by row rendering + // and by the compare-series label below. + const displayTitles = useMemo(() => disambiguateTitles(rows), [rows]); + + const onToggleSelect = useCallback((runId: string) => { + setSelectedRunIds((prev) => { + const next = new Set(prev); + if (next.has(runId)) { + next.delete(runId); + } else { + next.add(runId); + } + return next; + }); + }, []); + + const load = useCallback(() => { + fetchRuns({ limit: "200" }) + .then((body) => { setRows(body.runs); setError(null); }) + .catch((e: Error) => setError(e.message)) + .finally(() => setLoaded(true)); + }, []); + + useEffect(() => { + load(); + }, [load]); + + const anyActive = useMemo(() => rows.some((r) => ACTIVE_STATUSES.has(r.status)), [rows]); + useEffect(() => { + if (!anyActive) return; + const id = window.setInterval(load, POLL_MS); + return () => window.clearInterval(id); + }, [anyActive, load]); + + const groups = useMemo(() => groupRuns(rows), [rows]); + + useEffect(() => { + if (selectedRunIds.size === 0) { + setCompareInputs([]); + return; + } + let cancelled = false; + const ids = Array.from(selectedRunIds); + void Promise.all( + ids.map(async (runId) => { + // Live runs (queued/running) must not serve stale cached metrics: their + // loss history keeps growing while the row list polls, so always refetch. + // Terminal runs' metrics are immutable once finished, so caching is safe. + const row = rows.find((r) => r.run_id === runId); + const isLive = row != null && ACTIVE_STATUSES.has(row.status); + if (!isLive) { + const cached = metricsCacheRef.current.get(runId); + if (cached) return { runId, data: cached }; + } + const res = await fetchRunMetrics(runId); + if (!isLive) { + metricsCacheRef.current.set(runId, res.data); + } + return { runId, data: res.data }; + }), + ) + .then((results) => { + if (cancelled) return; + setCompareInputs( + results.map((r) => { + const row = rows.find((row) => row.run_id === r.runId); + const label = displayTitles.get(r.runId) ?? (row ? runDisplayTitle(row) : runDisplayTitle({ run_id: r.runId })); + return { runId: r.runId, label, data: r.data }; + }), + ); + }) + .catch((e: Error) => { + if (!cancelled) setError(e.message); + }); + return () => { + cancelled = true; + }; + // `rows` is included so a poll-driven reload re-runs this effect: live-run + // metrics bypass the cache above, terminal-run metrics still hit it. + }, [selectedRunIds, rows, displayTitles]); + + return ( + <aside className="cr-nodes-panel" aria-label="Runs"> + <header className="cr-nodes-panel__header cr-runs-panel__header"> + <h2 className="cr-nodes-panel__title">Runs</h2> + <button + type="button" + className="cr-runs-panel__refresh" + aria-label="Refresh" + onClick={load} + > + ↻ + </button> + </header> + {error ? <p className="cr-runs-panel__error">{error}</p> : null} + <div className="cr-nodes-panel__scroll"> + {loaded && !error && rows.length === 0 ? ( + <p className="cr-runs-panel__empty">Run a training to see it here.</p> + ) : null} + {groups.map((group) => { + const isExpanded = expanded[group.key] ?? !group.collapsedByDefault; + return ( + <section key={group.key || "__ungrouped"} className="cr-runs-panel__group"> + {group.key ? ( + <button + type="button" + className="cr-runs-panel__group-toggle" + data-testid={`run-group-toggle-${group.key}`} + aria-expanded={isExpanded} + onClick={() => setExpanded((s) => ({ ...s, [group.key]: !isExpanded }))} + > + <span className="cr-runs-panel__chevron" data-expanded={isExpanded}>▸</span> + <span className="cr-runs-panel__group-label">{group.label}</span> + <span className="cr-runs-panel__group-summary">{groupSummary(group.rows)}</span> + </button> + ) : null} + {isExpanded + ? group.rows.map((row) => { + const title = displayTitles.get(row.run_id) ?? runDisplayTitle(row); + const selected = selectedRunIds.has(row.run_id); + const duration = formatDuration(row.duration_seconds); + return ( + <div + key={row.run_id} + className="cr-runs-panel__row" + data-status={row.status} + data-selected={selected || undefined} + title={row.run_id} + > + <label className="cr-runs-panel__row-select"> + <input + type="checkbox" + className="cr-visually-hidden" + checked={selected} + onChange={() => onToggleSelect(row.run_id)} + aria-label={`Compare ${title}`} + /> + <span className="cr-runs-panel__status-dot" data-status={row.status} /> + <span className="cr-runs-panel__row-text"> + <span className="cr-runs-panel__row-main"> + <span className="cr-runs-panel__title">{title}</span> + <span className="cr-runs-panel__loss"> + {row.final_loss != null ? row.final_loss.toPrecision(3) : "—"} + </span> + </span> + <span className="cr-runs-panel__row-sub"> + {formatRelativeTime(row.created_at)} + {duration ? ` · ${duration}` : ""} + </span> + </span> + </label> + <span className="cr-runs-panel__actions"> + <button + type="button" + className="cr-runs-panel__icon-btn" + aria-label={`Open graph for ${title}`} + onClick={() => onOpenRunGraph(row.run_id)} + > + ↗ + </button> + {TERMINAL_DELETABLE.has(row.status) ? ( + <button + type="button" + className="cr-runs-panel__icon-btn" + aria-label={`Delete ${title}`} + onClick={() => { + void deleteRun(row.run_id) + .then(load) + .catch((e: Error) => setError(e.message)); + }} + > + ✕ + </button> + ) : null} + </span> + </div> + ); + }) + : null} + </section> + ); + })} + {selectedRunIds.size > 0 ? ( + <section className="cr-runs-panel__compare"> + <header className="cr-runs-panel__compare-header"> + <span>Compare ({selectedRunIds.size})</span> + <button + type="button" + className="cr-runs-panel__icon-btn" + onClick={() => setSelectedRunIds(new Set())} + > + Clear + </button> + </header> + <SweepVizLinePlot + chartId="runs-compare" + series={compareSeries} + xKey="step" + xIsNumeric + legendSummary="" + yAxisLabel="loss" + logScaleX={false} + logScaleY + showMarkers={false} + showLegendList={false} + /> + <ul className="cr-runs-panel__legend"> + {legend.map((entry) => ( + <li key={entry.runId} className="cr-runs-panel__legend-entry"> + <svg width="18" height="8" aria-hidden="true"> + <line x1="0" y1="4" x2="18" y2="4" stroke={entry.color} strokeWidth="2" /> + </svg> + {entry.hasTest ? ( + <svg width="18" height="8" aria-hidden="true"> + <line x1="0" y1="4" x2="18" y2="4" stroke={entry.color} strokeWidth="2" strokeDasharray="3 2" /> + </svg> + ) : null} + <span>{entry.title}</span> + </li> + ))} + </ul> + </section> + ) : null} + </div> + </aside> + ); +} diff --git a/frontend/src/components/railTypes.ts b/frontend/src/components/railTypes.ts index 608f5c0..c2ccf44 100644 --- a/frontend/src/components/railTypes.ts +++ b/frontend/src/components/railTypes.ts @@ -1,2 +1,2 @@ /** Primary rail destinations that swap the left sidebar panel. */ -export type RailPrimarySection = "nodes" | "observables" | "templates"; +export type RailPrimarySection = "nodes" | "observables" | "templates" | "runs"; diff --git a/frontend/src/graph/__tests__/leftNavRail.v1.seam.test.tsx b/frontend/src/graph/__tests__/leftNavRail.v1.seam.test.tsx index 6286b03..c62693c 100644 --- a/frontend/src/graph/__tests__/leftNavRail.v1.seam.test.tsx +++ b/frontend/src/graph/__tests__/leftNavRail.v1.seam.test.tsx @@ -52,7 +52,7 @@ beforeEach(() => { delete document.documentElement.dataset.crTheme; }); -const V1_RAIL_LABELS = ["Nodes", "Observables", "Templates"]; +const V1_RAIL_LABELS = ["Nodes", "Observables", "Templates", "Runs"]; function renderRail(host: HTMLElement) { const root = createRoot(host); diff --git a/frontend/src/graph/__tests__/researchCanvasOpenRunGraph.wiring.test.ts b/frontend/src/graph/__tests__/researchCanvasOpenRunGraph.wiring.test.ts new file mode 100644 index 0000000..ba02c83 --- /dev/null +++ b/frontend/src/graph/__tests__/researchCanvasOpenRunGraph.wiring.test.ts @@ -0,0 +1,50 @@ +/** + * Task 11 fix review (Finding 1): `openRunGraphInNewProject` must surface a + * failed `fetchRunRecord` (404 / network error) to the user the same way the + * file's other async handlers do (e.g. `deleteTemplateEntry`), instead of + * leaving an unhandled promise rejection with zero feedback. + * + * `ResearchCanvas` is a 6000+ line component wired to ReactFlow, several + * contexts, and browser-only APIs; mounting it in a unit test to exercise + * this one promise-rejection path is impractical (no existing test in this + * repo renders the full component — see `canvasNodeGesture.test.ts`'s + * "ResearchCanvas wiring" suite for the established alternative). This test + * follows that same established source-wiring pattern instead. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const canvasSource = readFileSync( + resolve(process.cwd(), "src/components/ResearchCanvas.tsx"), + "utf-8", +); + +function extractFunctionSource(name: string): string { + const start = canvasSource.indexOf(`const ${name} = useCallback(`); + expect(start, `${name} not found in ResearchCanvas.tsx`).toBeGreaterThanOrEqual(0); + const end = canvasSource.indexOf("\n }, [", start); + expect(end, `${name}'s closing "}, [...]" not found`).toBeGreaterThan(start); + return canvasSource.slice(start, end); +} + +describe("openRunGraphInNewProject wiring", () => { + const fnSource = extractFunctionSource("openRunGraphInNewProject"); + + it("fetches the run record", () => { + expect(fnSource).toContain("fetchRunRecord(runId)"); + }); + + it("catches a failed fetch instead of leaving an unhandled rejection", () => { + expect(fnSource).toContain(".catch("); + }); + + it("surfaces the failure via setNotice, matching the file's other error handlers", () => { + const catchIdx = fnSource.indexOf(".catch("); + const catchBody = fnSource.slice(catchIdx); + expect(catchBody).toContain("setNotice("); + // Same shape as deleteTemplateEntry's catch: prefer the real Error + // message, fall back to a short static string. + expect(catchBody).toMatch(/e instanceof Error \? e\.message : ".+"/); + }); +}); diff --git a/frontend/src/graph/__tests__/runCompareOverlay.test.ts b/frontend/src/graph/__tests__/runCompareOverlay.test.ts new file mode 100644 index 0000000..4d4708c --- /dev/null +++ b/frontend/src/graph/__tests__/runCompareOverlay.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "vitest"; + +import { buildRunCompareSeries, buildRunLegend, runSeriesColor } from "../runCompareOverlay"; + +test("one solid series per run, dashed test series, distinct colors", () => { + const series = buildRunCompareSeries([ + { runId: "run-a", label: "run-a", + data: { loss_history: [1, 0.5], test_loss_history: [2, 1], step_ticks: [0, 1] } }, + { runId: "run-b", label: "run-b", + data: { loss_history: [3, 2, 1], test_loss_history: [], step_ticks: [0, 1, 2] } }, + ]); + expect(series.map((s) => s.id)).toEqual(["run-a", "run-a:test", "run-b"]); + expect(series[0].points.map((p) => p.y)).toEqual([1, 0.5]); + expect(series[0].points.map((p) => p.x)).toEqual([0, 1]); + expect(series[1].strokeDasharray).toBeTruthy(); + expect(series[1].color).toBe(series[0].color); + expect(series[2].color).not.toBe(series[0].color); +}); + +test("falls back to index when step_ticks missing", () => { + const [s] = buildRunCompareSeries([ + { runId: "run-c", label: "run-c", data: { loss_history: [5, 4], step_ticks: [] } }, + ]); + expect(s.points.map((p) => p.x)).toEqual([0, 1]); +}); + +test("buildRunLegend: one entry per run, colors match series, hasTest flags", () => { + const inputs = [ + { runId: "run-a", label: "Trainer A", + data: { loss_history: [1], test_loss_history: [2], step_ticks: [0] } }, + { runId: "run-b", label: "Trainer B", + data: { loss_history: [3], test_loss_history: [], step_ticks: [0] } }, + ]; + const legend = buildRunLegend(inputs); + expect(legend).toEqual([ + { runId: "run-a", title: "Trainer A", color: runSeriesColor(0), hasTest: true }, + { runId: "run-b", title: "Trainer B", color: runSeriesColor(1), hasTest: false }, + ]); + const series = buildRunCompareSeries(inputs); + expect(series[0]!.color).toBe(legend[0]!.color); + expect(series[2]!.color).toBe(legend[1]!.color); +}); + +test("wrong-type series payload degrades to empty series instead of throwing", () => { + const badInputs = [ + { + runId: "run-bad", + label: "Bad Run", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: { loss_history: null, test_loss_history: "oops", step_ticks: 42 } as any, + }, + ]; + expect(() => buildRunCompareSeries(badInputs)).not.toThrow(); + expect(() => buildRunLegend(badInputs)).not.toThrow(); + + const series = buildRunCompareSeries(badInputs); + expect(series).toEqual([{ id: "run-bad", label: "Bad Run", color: runSeriesColor(0), points: [] }]); + + const legend = buildRunLegend(badInputs); + expect(legend).toEqual([{ runId: "run-bad", title: "Bad Run", color: runSeriesColor(0), hasTest: false }]); +}); diff --git a/frontend/src/graph/__tests__/runFormat.test.ts b/frontend/src/graph/__tests__/runFormat.test.ts new file mode 100644 index 0000000..52a9c1d --- /dev/null +++ b/frontend/src/graph/__tests__/runFormat.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "vitest"; + +import { disambiguateTitles, formatDuration, formatRelativeTime, runDisplayTitle } from "../runFormat"; + +const NOW = 1_700_000_000_000; + +test("formatRelativeTime buckets", () => { + expect(formatRelativeTime(NOW - 10_000, NOW)).toBe("just now"); + expect(formatRelativeTime(NOW - 3 * 60_000, NOW)).toBe("3m ago"); + expect(formatRelativeTime(NOW - 2 * 3_600_000, NOW)).toBe("2h ago"); + expect(formatRelativeTime(NOW - 30 * 3_600_000, NOW)).toBe("yesterday"); + // beyond 48h: a short date, not a relative phrase + const old = formatRelativeTime(NOW - 10 * 86_400_000, NOW); + expect(old).not.toMatch(/ago|now|yesterday/); + expect(old.length).toBeGreaterThan(0); +}); + +test("formatDuration", () => { + expect(formatDuration(null)).toBeNull(); + expect(formatDuration(12.4)).toBe("12s"); + expect(formatDuration(252)).toBe("4m12s"); + expect(formatDuration(3780)).toBe("1h03m"); +}); + +test("runDisplayTitle falls back to short hash", () => { + expect(runDisplayTitle({ trainer_title: "Grokking", run_id: "run-b00420904839" })).toBe("Grokking"); + expect(runDisplayTitle({ trainer_title: "", run_id: "run-b00420904839" })).toBe("run-b004…"); + expect(runDisplayTitle({ run_id: "run-b00420904839" })).toBe("run-b004…"); +}); + +test("disambiguateTitles suffixes rows sharing a title, leaves unique titles alone", () => { + const rows = [ + { run_id: "run-aaaa00000001", trainer_title: "Compare Trainer" }, + { run_id: "run-bbbb00000001", trainer_title: "Compare Trainer" }, + { run_id: "run-cccc00000001", trainer_title: "Unique Trainer" }, + ]; + const titles = disambiguateTitles(rows); + expect(titles.get("run-aaaa00000001")).toBe("Compare Trainer · run-aaaa"); + expect(titles.get("run-bbbb00000001")).toBe("Compare Trainer · run-bbbb"); + expect(titles.get("run-cccc00000001")).toBe("Unique Trainer"); +}); + +test("disambiguateTitles: three-way collision all get suffixed, empty input returns empty map", () => { + const rows = [ + { run_id: "run-1111111111", trainer_title: "Sweep Trainer" }, + { run_id: "run-2222222222", trainer_title: "Sweep Trainer" }, + { run_id: "run-3333333333", trainer_title: "Sweep Trainer" }, + ]; + const titles = disambiguateTitles(rows); + expect(titles.get("run-1111111111")).toBe("Sweep Trainer · run-1111"); + expect(titles.get("run-2222222222")).toBe("Sweep Trainer · run-2222"); + expect(titles.get("run-3333333333")).toBe("Sweep Trainer · run-3333"); + expect(disambiguateTitles([]).size).toBe(0); +}); diff --git a/frontend/src/graph/__tests__/runsPanel.test.tsx b/frontend/src/graph/__tests__/runsPanel.test.tsx new file mode 100644 index 0000000..b16115d --- /dev/null +++ b/frontend/src/graph/__tests__/runsPanel.test.tsx @@ -0,0 +1,352 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { RunsPanel } from "../../components/RunsPanel"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const NOW = Date.now(); + +const RUNS = { + runs: [ + { run_id: "run-aaa000000001", group_id: null, origin: "human", status: "completed", + created_at: NOW, finished_at: NOW + 1, trainer_node_id: "t1", trainer_title: "Trainer A", + final_loss: 0.25, final_test_loss: null, steps_completed: 4, duration_seconds: 1.5, hyperparams: {} }, + { run_id: "run-bbb000000001", group_id: "sweep-1", origin: "sweep", status: "failed", + created_at: NOW, finished_at: NOW + 1, trainer_node_id: "t1", trainer_title: "", + final_loss: null, final_test_loss: null, steps_completed: 0, duration_seconds: null, hyperparams: {} }, + ], + next_cursor: null, +}; + +let host: HTMLDivElement; + +beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (/\/api\/runs\/[^/]+\/metrics/.test(url)) { + return { ok: true, json: async () => ({ source: "test", data: { loss_history: [1, 2], step_ticks: [0, 1] } }) }; + } + return { ok: true, json: async () => RUNS }; + }) as unknown as typeof fetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + host.remove(); +}); + +test("renders run rows grouped, sweep group collapsed by default", async () => { + const root = createRoot(host); + await act(async () => { + root.render(<RunsPanel onOpenRunGraph={() => {}} />); + }); + await act(async () => { await Promise.resolve(); }); + + expect(host.textContent).toContain("Trainer A"); + expect(host.textContent).not.toContain("run-aaa000000001"); + expect(host.textContent).toContain("sweep-1"); + // collapsed group hides its member row until expanded + expect(host.textContent).not.toContain("run-bbb0…"); + + const toggle = host.querySelector<HTMLButtonElement>('[data-testid="run-group-toggle-sweep-1"]'); + expect(toggle).not.toBeNull(); + expect(toggle!.textContent).toContain("1 run"); + + await act(async () => { toggle!.click(); }); + expect(host.textContent).toContain("run-bbb0…"); + + const checkboxA = host.querySelector<HTMLInputElement>('[aria-label="Compare Trainer A"]'); + expect(checkboxA).not.toBeNull(); + const rowA = checkboxA!.closest(".cr-runs-panel__row"); + expect(rowA).not.toBeNull(); + expect(rowA!.getAttribute("data-selected")).toBeNull(); + await act(async () => { checkboxA!.click(); }); + expect(rowA!.getAttribute("data-selected")).toBe("true"); + + expect(host.querySelector('button[aria-label="Open graph for Trainer A"]')).not.toBeNull(); + expect(host.querySelector('button[aria-label="Delete Trainer A"]')).not.toBeNull(); + + expect(host.textContent).toMatch(/ago|just now/); + + expect(host.querySelector('button[aria-label="Refresh"]')).not.toBeNull(); + expect( + Array.from(host.querySelectorAll("button")).some((b) => b.textContent === "Refresh"), + ).toBe(false); + + await act(async () => { root.unmount(); }); +}); + +test("shows the empty state when there are no runs", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: true, + json: async () => ({ runs: [], next_cursor: null }), + })) as unknown as typeof fetch); + + const root = createRoot(host); + await act(async () => { + root.render(<RunsPanel onOpenRunGraph={() => {}} />); + }); + await act(async () => { await Promise.resolve(); }); + + expect(host.textContent).toContain("Run a training to see it here."); + + await act(async () => { root.unmount(); }); +}); + +test("surfaces an error and keeps the row when deleting a run fails", async () => { + vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "DELETE") { + return { + ok: false, + status: 500, + statusText: "Internal Server Error", + text: async () => "boom", + json: async () => ({}), + }; + } + return { ok: true, json: async () => RUNS }; + }) as unknown as typeof fetch); + + const root = createRoot(host); + await act(async () => { + root.render(<RunsPanel onOpenRunGraph={() => {}} />); + }); + await act(async () => { await Promise.resolve(); }); + + const deleteBtn = host.querySelector<HTMLButtonElement>('button[aria-label="Delete Trainer A"]'); + expect(deleteBtn).not.toBeNull(); + await act(async () => { + deleteBtn!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const errorEl = host.querySelector(".cr-runs-panel__error"); + expect(errorEl).not.toBeNull(); + expect(errorEl!.textContent).toContain("boom"); + // the row must still be present since the delete failed + expect(host.textContent).toContain("Trainer A"); + + await act(async () => { root.unmount(); }); +}); + +test("refresh re-fetches metrics for a live selected run but reuses the cache for a terminal one", async () => { + const runsBody = { + runs: [ + { run_id: "run-live0000001", group_id: null, origin: "human", status: "running", + created_at: NOW, finished_at: null, trainer_node_id: "t1", trainer_title: "Live Trainer", + final_loss: null, final_test_loss: null, steps_completed: 2, duration_seconds: null, hyperparams: {} }, + { run_id: "run-done0000001", group_id: null, origin: "human", status: "completed", + created_at: NOW, finished_at: NOW + 1, trainer_node_id: "t1", trainer_title: "Done Trainer", + final_loss: 0.1, final_test_loss: null, steps_completed: 4, duration_seconds: 1, hyperparams: {} }, + ], + next_cursor: null, + }; + const metricsCalls: Record<string, number> = {}; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const m = /\/api\/runs\/([^/]+)\/metrics/.exec(url); + if (m) { + const runId = m[1]!; + metricsCalls[runId] = (metricsCalls[runId] ?? 0) + 1; + return { ok: true, json: async () => ({ source: "test", data: { loss_history: [1, 2], step_ticks: [0, 1] } }) }; + } + // Mirror a real fetch: every call decodes a fresh object/array, so the + // `rows` array reference changes on every poll even when content doesn't. + return { ok: true, json: async () => JSON.parse(JSON.stringify(runsBody)) }; + }) as unknown as typeof fetch); + + const root = createRoot(host); + await act(async () => { + root.render(<RunsPanel onOpenRunGraph={() => {}} />); + }); + await act(async () => { await Promise.resolve(); }); + + const liveCheckbox = host.querySelector<HTMLInputElement>('[aria-label="Compare Live Trainer"]'); + const doneCheckbox = host.querySelector<HTMLInputElement>('[aria-label="Compare Done Trainer"]'); + expect(liveCheckbox).not.toBeNull(); + expect(doneCheckbox).not.toBeNull(); + + const flush = async () => { + for (let i = 0; i < 8; i++) { + await Promise.resolve(); + } + }; + + await act(async () => { + liveCheckbox!.click(); + await flush(); + }); + await act(async () => { + doneCheckbox!.click(); + await flush(); + }); + + expect(metricsCalls["run-live0000001"]).toBeGreaterThanOrEqual(1); + expect(metricsCalls["run-done0000001"]).toBe(1); + const liveCallsBeforeRefresh = metricsCalls["run-live0000001"]!; + + const refreshBtn = host.querySelector<HTMLButtonElement>('button[aria-label="Refresh"]'); + expect(refreshBtn).toBeTruthy(); + await act(async () => { + refreshBtn!.click(); + await flush(); + }); + + // the live run's metrics must be refetched since its history can still grow + expect(metricsCalls["run-live0000001"]).toBeGreaterThan(liveCallsBeforeRefresh); + // the terminal run's metrics are immutable, so the cache is reused + expect(metricsCalls["run-done0000001"]).toBe(1); + + await act(async () => { root.unmount(); }); +}); + +test("header uses an h2 title and the panel body sits inside a scroll wrapper", async () => { + const root = createRoot(host); + await act(async () => { + root.render(<RunsPanel onOpenRunGraph={() => {}} />); + }); + await act(async () => { await Promise.resolve(); }); + + const heading = host.querySelector("h2.cr-nodes-panel__title"); + expect(heading).not.toBeNull(); + expect(heading!.textContent).toBe("Runs"); + + const header = host.querySelector("header.cr-nodes-panel__header.cr-runs-panel__header"); + expect(header).not.toBeNull(); + expect(header!.contains(heading!)).toBe(true); + + const scroll = host.querySelector(".cr-nodes-panel__scroll"); + expect(scroll).not.toBeNull(); + // groups render inside the scroll wrapper, not as direct siblings of it + expect(scroll!.querySelector(".cr-runs-panel__group")).not.toBeNull(); + + await act(async () => { root.unmount(); }); +}); + +test("runs sharing a sweep title get disambiguated aria-labels and legend entries", async () => { + const dupRuns = { + runs: [ + { run_id: "run-aaa000000001", group_id: "sweep-dup", origin: "sweep", status: "completed", + created_at: NOW, finished_at: NOW + 1, trainer_node_id: "t1", trainer_title: "Trainer", + final_loss: 0.3, final_test_loss: null, steps_completed: 4, duration_seconds: 1, hyperparams: {} }, + { run_id: "run-bbb000000001", group_id: "sweep-dup", origin: "sweep", status: "completed", + created_at: NOW, finished_at: NOW + 1, trainer_node_id: "t1", trainer_title: "Trainer", + final_loss: 0.4, final_test_loss: null, steps_completed: 4, duration_seconds: 1, hyperparams: {} }, + ], + next_cursor: null, + }; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (/\/api\/runs\/[^/]+\/metrics/.test(url)) { + return { ok: true, json: async () => ({ source: "test", data: { loss_history: [1, 2], step_ticks: [0, 1] } }) }; + } + return { ok: true, json: async () => dupRuns }; + }) as unknown as typeof fetch); + + const root = createRoot(host); + await act(async () => { + root.render(<RunsPanel onOpenRunGraph={() => {}} />); + }); + await act(async () => { await Promise.resolve(); }); + + const toggle = host.querySelector<HTMLButtonElement>('[data-testid="run-group-toggle-sweep-dup"]'); + expect(toggle).not.toBeNull(); + expect(toggle!.getAttribute("aria-expanded")).toBe("false"); + await act(async () => { toggle!.click(); }); + expect(toggle!.getAttribute("aria-expanded")).toBe("true"); + + const checkboxA = host.querySelector<HTMLInputElement>('[aria-label="Compare Trainer · run-aaa0"]'); + const checkboxB = host.querySelector<HTMLInputElement>('[aria-label="Compare Trainer · run-bbb0"]'); + expect(checkboxA).not.toBeNull(); + expect(checkboxB).not.toBeNull(); + // the raw, ambiguous title must not appear as an aria-label on its own + expect(host.querySelector('[aria-label="Compare Trainer"]')).toBeNull(); + + const flush = async () => { + for (let i = 0; i < 8; i++) { + await Promise.resolve(); + } + }; + await act(async () => { + checkboxA!.click(); + await flush(); + }); + await act(async () => { + checkboxB!.click(); + await flush(); + }); + + const legendItems = Array.from(host.querySelectorAll(".cr-runs-panel__legend-entry")); + expect(legendItems.map((li) => li.textContent)).toEqual([ + "Trainer · run-aaa0", + "Trainer · run-bbb0", + ]); + + await act(async () => { root.unmount(); }); +}); + +test("compare section: heading count, legend titles, and Clear resets selection", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const m = /\/api\/runs\/([^/]+)\/metrics/.exec(url); + if (m) { + return { ok: true, json: async () => ({ source: "test", data: { loss_history: [1, 2], step_ticks: [0, 1] } }) }; + } + return { ok: true, json: async () => RUNS }; + }) as unknown as typeof fetch); + + const root = createRoot(host); + await act(async () => { + root.render(<RunsPanel onOpenRunGraph={() => {}} />); + }); + await act(async () => { await Promise.resolve(); }); + + const toggle = host.querySelector<HTMLButtonElement>('[data-testid="run-group-toggle-sweep-1"]'); + await act(async () => { toggle!.click(); }); + + const checkboxA = host.querySelector<HTMLInputElement>('[aria-label="Compare Trainer A"]'); + const checkboxB = host.querySelector<HTMLInputElement>('[aria-label="Compare run-bbb0…"]'); + expect(checkboxA).not.toBeNull(); + expect(checkboxB).not.toBeNull(); + + const flush = async () => { + for (let i = 0; i < 8; i++) { + await Promise.resolve(); + } + }; + + await act(async () => { + checkboxA!.click(); + await flush(); + }); + await act(async () => { + checkboxB!.click(); + await flush(); + }); + + const compareSection = host.querySelector(".cr-runs-panel__compare"); + expect(compareSection).not.toBeNull(); + expect(compareSection!.textContent).toContain("Compare (2)"); + + const legendItems = Array.from(host.querySelectorAll(".cr-runs-panel__legend-entry")); + expect(legendItems.length).toBe(2); + expect(legendItems.map((li) => li.textContent)).toEqual(["Trainer A", "run-bbb0…"]); + + const clearBtn = Array.from(host.querySelectorAll("button")).find((b) => b.textContent === "Clear"); + expect(clearBtn).not.toBeUndefined(); + await act(async () => { + clearBtn!.click(); + await flush(); + }); + + expect(host.querySelectorAll("[data-selected]").length).toBe(0); + expect(host.querySelector(".cr-runs-panel__compare")).toBeNull(); + + await act(async () => { root.unmount(); }); +}); diff --git a/frontend/src/graph/readNdjsonTrainStream.ts b/frontend/src/graph/readNdjsonTrainStream.ts index acfe8d4..ec6c36d 100644 --- a/frontend/src/graph/readNdjsonTrainStream.ts +++ b/frontend/src/graph/readNdjsonTrainStream.ts @@ -99,7 +99,8 @@ export async function readNdjsonTrainStream( | TrainStreamPaused | TrainStreamAborted | TrainStreamError - | { type: "remote_session" }; + | { type: "remote_session" } + | { type: "run_registered"; run_id: string }; if (ev.type === "complete") complete = ev; else if (ev.type === "paused") paused = ev; else if (ev.type === "aborted") aborted = true; @@ -109,6 +110,8 @@ export async function readNdjsonTrainStream( else if (ev.type === "metrics") options?.onMetrics?.(ev); else if (ev.type === "remote_session") { options?.onRemoteSession?.(); + } else if (ev.type === "run_registered") { + // No-op: run id is not currently surfaced to the UI. } else onProgress(ev as TrainStreamProgress); }; diff --git a/frontend/src/graph/runCompareOverlay.ts b/frontend/src/graph/runCompareOverlay.ts new file mode 100644 index 0000000..07b6e71 --- /dev/null +++ b/frontend/src/graph/runCompareOverlay.ts @@ -0,0 +1,53 @@ +import { SERIES_COLORS, type PlotPoint, type PlotSeries } from "./sweepVizPlot"; + +export type RunSeriesInput = { runId: string; label: string; data: Record<string, number[]> }; + +export function runSeriesColor(index: number): string { + return SERIES_COLORS[index % SERIES_COLORS.length]!; +} + +export type RunLegendEntry = { runId: string; title: string; color: string; hasTest: boolean }; + +export function buildRunLegend(inputs: RunSeriesInput[]): RunLegendEntry[] { + return inputs.map((input, i) => { + const test = input.data.test_loss_history; + return { + runId: input.runId, + title: input.label, + color: runSeriesColor(i), + hasTest: Array.isArray(test) && test.length > 0, + }; + }); +} + +function toPoints(ys: number[], xs: number[], rowId: string): PlotPoint[] { + return ys + .map((y, i) => ({ x: xs[i] ?? i, xDisplay: String(xs[i] ?? i), y, rowId })) + .filter((p) => Number.isFinite(p.y)); +} + +export function buildRunCompareSeries(inputs: RunSeriesInput[]): PlotSeries[] { + const series: PlotSeries[] = []; + inputs.forEach((input, i) => { + const color = runSeriesColor(i); + const steps = Array.isArray(input.data.step_ticks) ? input.data.step_ticks : []; + const loss = Array.isArray(input.data.loss_history) ? input.data.loss_history : []; + series.push({ + id: input.runId, + label: input.label, + color, + points: toPoints(loss, steps, input.runId), + }); + const test = Array.isArray(input.data.test_loss_history) ? input.data.test_loss_history : []; + if (test.length) { + series.push({ + id: `${input.runId}:test`, + label: `${input.label} (test)`, + color, + strokeDasharray: "4 3", + points: toPoints(test, steps, input.runId), + }); + } + }); + return series; +} diff --git a/frontend/src/graph/runFormat.ts b/frontend/src/graph/runFormat.ts new file mode 100644 index 0000000..285d137 --- /dev/null +++ b/frontend/src/graph/runFormat.ts @@ -0,0 +1,46 @@ +export function formatRelativeTime(ms: number, nowMs: number = Date.now()): string { + const delta = Math.max(0, nowMs - ms); + if (delta < 45_000) return "just now"; + if (delta < 3_600_000) return `${Math.round(delta / 60_000)}m ago`; + if (delta < 86_400_000) return `${Math.round(delta / 3_600_000)}h ago`; + if (delta < 2 * 86_400_000) return "yesterday"; + return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +export function formatDuration(seconds: number | null): string | null { + if (seconds == null || !Number.isFinite(seconds)) return null; + const s = Math.round(seconds); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`; + return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`; +} + +export function runDisplayTitle(row: { trainer_title?: string; run_id: string }): string { + const title = (row.trainer_title ?? "").trim(); + return title ? title : `${row.run_id.slice(0, 8)}…`; +} + +/** + * Runs that share a sweep (or otherwise share a trainer_title) all resolve to + * the same `runDisplayTitle`, which collapses their accessible names and + * legend labels into indistinguishable duplicates. This computes, per call, + * the disambiguated DISPLAY title for every row: rows whose base title + * appears more than once among the given rows get `${title} · ${shortId}` + * appended; rows with a unique title are left as-is. + */ +export function disambiguateTitles(rows: { run_id: string; trainer_title?: string }[]): Map<string, string> { + const baseTitles = new Map<string, string>(); + const counts = new Map<string, number>(); + for (const row of rows) { + const base = runDisplayTitle(row); + baseTitles.set(row.run_id, base); + counts.set(base, (counts.get(base) ?? 0) + 1); + } + const result = new Map<string, string>(); + for (const row of rows) { + const base = baseTitles.get(row.run_id)!; + const isDuplicate = (counts.get(base) ?? 0) > 1; + result.set(row.run_id, isDuplicate ? `${base} · ${row.run_id.slice(0, 8)}` : base); + } + return result; +} diff --git a/frontend/src/graph/runsApi.ts b/frontend/src/graph/runsApi.ts new file mode 100644 index 0000000..32b3639 --- /dev/null +++ b/frontend/src/graph/runsApi.ts @@ -0,0 +1,48 @@ +import type { GraphDocument } from "../types/graph"; + +export type RunRow = { + run_id: string; + group_id: string | null; + origin: string; + status: string; + created_at: number; + finished_at: number | null; + trainer_node_id: string; + trainer_title: string; + final_loss: number | null; + final_test_loss: number | null; + steps_completed: number | null; + duration_seconds: number | null; + hyperparams: Record<string, unknown>; +}; + +async function readJson<T>(res: Response): Promise<T> { + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || res.statusText); + } + return (await res.json()) as T; +} + +export async function fetchRuns( + params?: Record<string, string>, +): Promise<{ runs: RunRow[]; next_cursor: string | null }> { + const qs = params ? `?${new URLSearchParams(params)}` : ""; + return readJson(await fetch(`/api/runs${qs}`, { cache: "no-store" })); +} + +export async function fetchRunMetrics( + runId: string, +): Promise<{ source: string; data: Record<string, number[]> }> { + return readJson(await fetch(`/api/runs/${runId}/metrics`, { cache: "no-store" })); +} + +export async function fetchRunRecord( + runId: string, +): Promise<{ graph: GraphDocument } & Record<string, unknown>> { + return readJson(await fetch(`/api/runs/${runId}`, { cache: "no-store" })); +} + +export async function deleteRun(runId: string): Promise<void> { + await readJson(await fetch(`/api/runs/${runId}`, { method: "DELETE" })); +} diff --git a/frontend/src/graph/sweepVizPlot.ts b/frontend/src/graph/sweepVizPlot.ts index 3165670..2db548b 100644 --- a/frontend/src/graph/sweepVizPlot.ts +++ b/frontend/src/graph/sweepVizPlot.ts @@ -106,7 +106,7 @@ export function dualAxisWarranted(series: PlotSeries[]): boolean { /* Theme-aware: resolved per active theme by tokens.css (classic values are * the exact legacy hex palette). Consumed as SVG inline styles, which * resolve var() at render time. */ -const SERIES_COLORS = [ +export const SERIES_COLORS = [ "var(--cr-chart-1)", "var(--cr-chart-2)", "var(--cr-chart-3)", diff --git a/frontend/src/index.css b/frontend/src/index.css index 0b9e4a3..29f4925 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1556,6 +1556,109 @@ html.cr-node-over-library-delete .cr-nodes-panel::after { letter-spacing: 0.02em; } +.cr-visually-hidden { + position: absolute; width: 1px; height: 1px; margin: -1px; + padding: 0; border: 0; clip: rect(0 0 0 0); overflow: hidden; white-space: nowrap; +} +.cr-runs-panel__header { + display: flex; + align-items: center; + justify-content: space-between; +} +.cr-runs-panel__header .cr-nodes-panel__title { + margin: 0; +} +.cr-runs-panel__refresh, +.cr-runs-panel__icon-btn { + background: none; border: none; cursor: pointer; + color: var(--cr-text-4); font-size: 13px; padding: 2px 4px; border-radius: 4px; +} +.cr-runs-panel__refresh:hover, .cr-runs-panel__refresh:focus-visible, +.cr-runs-panel__icon-btn:hover, .cr-runs-panel__icon-btn:focus-visible { + color: var(--cr-text-2); background: var(--cr-surface-2, var(--cr-surface-1)); +} +.cr-runs-panel__compare { + margin-top: 8px; padding: 8px; border-top: 1px solid var(--cr-hairline); +} +.cr-runs-panel__compare .cr-tviz-chart { width: 100%; height: auto; } +.cr-runs-panel__compare-header { + display: flex; align-items: center; justify-content: space-between; + font-size: 12px; font-weight: 500; color: var(--cr-text-2); margin-bottom: 6px; +} +.cr-runs-panel__legend { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; } +.cr-runs-panel__legend-entry { + display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--cr-text-2); +} +.cr-runs-panel__group { + display: flex; + flex-direction: column; +} + +.cr-runs-panel__row { + display: flex; align-items: center; gap: 4px; + padding: 5px 8px 5px 6px; border-left: 2px solid transparent; border-radius: 4px; +} +.cr-runs-panel__row[data-selected] { + border-left-color: var(--cr-accent); + background: var(--cr-surface-2, var(--cr-surface-1)); +} +.cr-runs-panel__row-select { + display: flex; align-items: center; gap: 7px; flex: 1; min-width: 0; cursor: pointer; +} +.cr-runs-panel__row:has(.cr-runs-panel__row-select input:focus-visible) { + outline: 2px solid var(--cr-accent); outline-offset: -2px; +} +.cr-runs-panel__row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; } +.cr-runs-panel__row-main { display: flex; align-items: baseline; gap: 8px; } +.cr-runs-panel__title { + flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 13px; font-weight: 500; color: var(--cr-text-1); +} +.cr-runs-panel__loss { + font-family: var(--cr-font-mono, monospace); font-size: 12px; color: var(--cr-text-2); +} +.cr-runs-panel__row-sub { font-size: 11px; color: var(--cr-text-3); } +.cr-runs-panel__actions { display: flex; align-items: center; gap: 0; } + +.cr-runs-panel__status-dot { + width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--cr-text-4); +} + +.cr-runs-panel__status-dot[data-status="completed"] { + background: var(--cr-chart-2); +} + +.cr-runs-panel__status-dot[data-status="failed"], +.cr-runs-panel__status-dot[data-status="crashed"] { + background: var(--cr-chart-1); +} +.cr-runs-panel__status-dot[data-status="running"] { + background: var(--cr-accent); animation: cr-runs-pulse 1.4s ease-in-out infinite; +} +.cr-runs-panel__status-dot[data-status="aborted"], +.cr-runs-panel__status-dot[data-status="paused"], +.cr-runs-panel__status-dot[data-status="unreadable"] { + background: transparent; border: 1.5px solid var(--cr-text-4); +} +@keyframes cr-runs-pulse { 50% { opacity: 0.35; } } +.cr-runs-panel__group-toggle { + display: flex; align-items: center; gap: 6px; width: 100%; + background: none; border: none; cursor: pointer; padding: 6px 8px; + font-size: 12px; color: var(--cr-text-2); text-align: left; +} +.cr-runs-panel__chevron { transition: transform 120ms ease; color: var(--cr-text-4); } +.cr-runs-panel__chevron[data-expanded="true"] { transform: rotate(90deg); } +.cr-runs-panel__group-label { font-weight: 500; } +.cr-runs-panel__group-summary { color: var(--cr-text-3); font-size: 11px; } +.cr-runs-panel__group .cr-runs-panel__row { margin-left: 10px; } +.cr-runs-panel__empty { + padding: 24px 12px; text-align: center; font-size: 12px; color: var(--cr-text-3); +} + +.cr-runs-panel__error { + color: var(--cr-chart-1); font-size: 12px; padding: 6px 10px; margin: 0; +} + .cr-nodes-panel__search-row { display: flex; align-items: center; diff --git a/scripts/rebuild_run_index.py b/scripts/rebuild_run_index.py new file mode 100644 index 0000000..9886179 --- /dev/null +++ b/scripts/rebuild_run_index.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Rebuild ``data/runs/index.db`` from the on-disk ``run-*/run.json`` files. + +Thin CLI wrapper around +``comfy_research.engine.runs.run_index.rebuild_index()``. The server already +self-heals a missing, corrupt, or undercounted index at startup (see +``comfy_research/main.py``'s lifespan), so this script is for cases outside +that path: forcing a rebuild without restarting the server, or running it +from a script or CI step. +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from comfy_research.engine.runs.run_index import rebuild_index # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.parse_args() + count = rebuild_index() + print(f"Rebuilt data/runs/index.db from {count} run.json file(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_docs_source_contracts.py b/tests/test_docs_source_contracts.py index df6011c..6031913 100644 --- a/tests/test_docs_source_contracts.py +++ b/tests/test_docs_source_contracts.py @@ -52,6 +52,7 @@ def test_docs_pages_publish_identity_and_governance() -> None: "reference/index.md": "overview", "reference/application.md": "reference", "reference/training-api.md": "reference", + "reference/runs-api.md": "reference", "reference/data-contracts.md": "reference", "reference/node-contracts.md": "reference", "reference/support-status.md": "reference",