Skip to content

feat(migrate): wandb → Pluto historical data migration - #131

Merged
asaiacai merged 58 commits into
mainfrom
feat/wandb-migrate
Aug 13, 2026
Merged

feat(migrate): wandb → Pluto historical data migration#131
asaiacai merged 58 commits into
mainfrom
feat/wandb-migrate

Conversation

@asaiacai

@asaiacai asaiacai commented Jul 7, 2026

Copy link
Copy Markdown

Summary

Two-phase migration tool for importing legacy wandb data into Pluto with full timestamp fidelity — built for the first customer coming off the wandb shim who wants their historical runs transferred.

  • pluto migrate wandb export — stages complete runs from the wandb cloud API on disk (parquet long-schema parts + run.json manifest + media/artifact files): full scan_history metrics, media, tables/histograms, events-stream system metrics, console logs, artifacts (size-cappable). Atomic per-run staging with sentinel-based resume.
  • pluto migrate wandb load — replays staged runs through the public client API with original wall-clock timestamps, wandb::{entity}/{project}/{run_id} external-id dedup, crash-healing resume (typed RunExistsError → resume + re-replay), batched metric enqueue, bounded backpressure, and --dry-run.
  • pluto migrate wandb all — both phases; loads whatever staged even if some exports failed.

Client groundwork

  • op.log(..., timestamp=) — epoch-seconds override threaded through the sync store (wire format already carried per-point time); works in sync, legacy, and perf-queue modes.
  • Op._log_console / Op._log_metrics_batch — batched backfill helpers.
  • settings.disable_system_metrics — keeps the migration host's hardware/health metrics (sys/*, sys/pluto.*, run systemMetadata) out of imported runs, including in the sync subprocess.
  • New migrate extra (pip install 'pluto-ml[migrate]'): wandb + pyarrow, lazily imported so the base CLI/package are unaffected.

Server dependency

Run createdAt/updatedAt backfill needs the companion server PR (Trainy-ai/server-private branch feat/run-createdat-backfill); metric/file/console point timestamps already round-trip against today's prod ingest. Until it deploys, imported runs show import-day creation dates only.

Test plan

  • 71 new/updated unit tests (TDD): timestamp threading, monitor suppression, parquet round-trip/rotation, exporter (fake wandb API fixtures), loader (mocked init), CLI wiring.
  • tests/test_migrate_staging_e2e.py: live round-trip against the dev channel, gated on PLUTO_STAGING_API_KEY.
  • Multi-angle code review with 10 confirmed findings — all fixed in the final commit.

🤖 Generated with Claude Code


Note

High Risk
Large new migration and ingest surface (historical timestamps, run lifecycle, bulk replay, auth, and file uploads) with high blast radius if replay or status handling is wrong; server backfill for run createdAt/statusUpdated is a deployment dependency.

Overview
Adds a wandb → Pluto migration pipeline (pluto migrate wandb export | load | all) with on-disk parquet staging, resumable export/load ledgers, parallel project workers, and coverage reporting. The loader replays runs through the public client with historical wall-clock times, wandb::… external ids, terminal-status verification/healing, and optional staged-file cleanup.

Backfill-oriented client changes: log(..., timestamp=) (epoch seconds), Op._log_metrics_batch / _log_console, settings.disable_system_metrics, compat createdAt/updatedAt/systemMetadata and statusUpdated on finish, plus RunExistsError for intentional resume. Terminal status updates retry connection resets and surface failure via _status_update_error. Login no longer overwrites an explicitly provided API key on a failed validation POST.

Media: Image supports boxes, masks, and annotations (mask PNGs upload as fileType mask); sync/file upload carries annotations.

Also ships client-side hyperparameter sweeps (pluto.sweep / pluto.agent, grid/random/bayes via optuna) with init() merging sampled config and sweep:<id> tags—aligned with migrated sweep metadata.

Sync subprocess is terminated after drain so long bulk loads do not accumulate daemons; string metrics route to string-series ingest. Docs/API meta updated for timestamp, Image params, and sweep symbols. Requires pluto-ml[migrate] extra for wandb/pyarrow (lazy-loaded from CLI).

Reviewed by Cursor Bugbot for commit 3000597. Configure here.

Summary by CodeRabbit

  • New Features
    • Added pluto migrate wandb CLI with export, load, and all workflows, resumable staging/loading, project scoping, and configurable parallel workers.
    • Added optional timestamp support for log(...) to improve historical replay fidelity.
    • Added disable_system_metrics to suppress host hardware/system metrics during backfills and migrations.
  • Bug Fixes
    • Prevented explicitly provided auth tokens from being overwritten after transient connectivity failures.
    • Backfilled/migrated compatibility payload now preserves historical statusUpdated timing.
  • Documentation
    • Updated log(...) documentation to explain timestamp semantics and invalid-value handling.

Ubuntu and others added 8 commits July 7, 2026 03:32
… disable_system_metrics

Groundwork for pluto.migrate (wandb importer): explicit historical
timestamps thread through to the sync layer (wire format already
carried them), console lines can be replayed with original times, and
the importing host's system metrics can be suppressed so they don't
pollute migrated runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Long-schema PartWriter with size-based part rotation, part readers,
atomic-JSON state helpers, export sentinels, and the load-phase
LoadedCache. wandb+pyarrow become the optional 'migrate' extra.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full-fidelity export via wandb.Api: scan_history metrics with original
step/timestamp, media/histogram rows, events-stream system metrics
(renamed system.* -> sys/*), console lines from output.log (parsing
per-line timestamps when present), artifacts with a size cap, and
atomic per-run staging with sentinel-based resume.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-step metric replay with original timestamps, run createdAt via
settings.compat, media/table/histogram conversion, console + artifact
replay, sync-queue backpressure, finish-code mapping, external-id
dedup, loaded-cache resume, and dry-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Thin argparse layer over WandbExporter/PlutoLoader; heavy deps import
inside handlers so the base CLI works without the migrate extra.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Loads a hand-staged export into the dev/staging environment via
PlutoLoader and verifies through pluto.query that historical metric
timestamps, tags, and (once the server fix deploys) run createdAt
round-trip. Gated on PLUTO_STAGING_API_KEY; URLs default to the
pluto-*-dev.trainy.ai channel and are overridable via env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- perf-mode log() now carries timestamp through the queue (was silently
  dropped when sync is disabled)
- collision on externalId resumes and re-replays instead of permanently
  marking a half-loaded run as done (typed RunExistsError in op.py)
- metric replay batches groups through one SQLite transaction
  (Op._log_metrics_batch / SyncProcessManager.enqueue_metrics_batch)
- disable_system_metrics now reaches the sync subprocess (sys/pluto.*
  health metrics no longer stamp migrated runs with current time) and
  suppresses host systemMetadata on run creation
- backpressure wait is bounded (stall_timeout) with guarded polling
- staged system metrics keep source-native names; loader owns the sys/
  translation; console lines are no longer rewritten when they carry
  their own timestamps
- 'all' loads staged runs even when some exports failed; 'all --dry-run'
  is rejected instead of silently exporting; --artifact-max-size-mb 0
  means a zero cap, not unlimited

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a robust two-phase migration pipeline (pluto.migrate) to export historical experiment data from Weights & Biases to local parquet files and load them into Pluto while preserving original timestamps. The changes include new CLI commands, exporter and loader modules, resume bookkeeping, and support for historical timestamps and batch logging in the core Op class. The code review identified critical path traversal vulnerabilities when handling externally-sourced identifiers (such as run IDs, file names, and artifact names) from the wandb API and staged files. Additionally, several bugs and robustness issues were highlighted, including a missing defaultdict import in pluto/op.py, potential crashes from malformed JSON or non-string inputs, uncleaned temporary directories on export failure, and platform-dependent file opening without explicit UTF-8 encoding.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread pluto/op.py
Comment on lines +701 to +702
new_metric_names: List[str] = []
new_file_meta: Dict[str, List[str]] = defaultdict(list)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The defaultdict class is used here but it is not imported in this file, which will raise a NameError when _log_metrics_batch is called. Import defaultdict from collections locally or at the top of the file to prevent this crash.

Suggested change
new_metric_names: List[str] = []
new_file_meta: Dict[str, List[str]] = defaultdict(list)
from collections import defaultdict
new_metric_names: List[str] = []
new_file_meta: Dict[str, List[str]] = defaultdict(list)

if self.before_ms is not None and created_ms > self.before_ms:
continue

run_dir = runs_root / run.id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The run.id is an externally-sourced identifier from the wandb API. Using it directly to construct run_dir can lead to path traversal vulnerabilities if a malicious run ID contains .. or path separators. Sanitize it using Path(run.id).name and check for unsafe values like . or .. before constructing the path.

Suggested change
run_dir = runs_root / run.id
safe_run_id = Path(run.id).name
if not safe_run_id or safe_run_id in ('.', '..'):
logger.warning(f'{tag}: unsafe run ID {run.id!r}, skipping')
continue
run_dir = runs_root / safe_run_id
References
  1. Sanitize externally-sourced identifiers before using them to construct file paths to prevent path traversal vulnerabilities. Use pathlib.Path(identifier).name or os.path.basename(identifier) and check for unsafe values like . or ...

Comment thread pluto/migrate/wandb_export.py Outdated
Comment on lines +318 to +322
for f in run.files():
if not self.include_files and f.name != 'output.log':
continue
try:
f.download(root=str(files_dir), exist_ok=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The f.name is an externally-sourced file path from the wandb API. To prevent path traversal vulnerabilities, sanitize it using Path(f.name).name and check for unsafe values like . or .. before downloading.

        for f in run.files():
            if not self.include_files and f.name != 'output.log':
                continue
            safe_name = Path(f.name).name
            if not safe_name or safe_name in ('.', '..') or safe_name != f.name:
                logger.warning(f'{tag}: unsafe file name {f.name!r}, skipping')
                continue
            try:
                f.download(root=str(files_dir), exist_ok=True)
References
  1. Sanitize externally-sourced identifiers before using them to construct file paths to prevent path traversal vulnerabilities. Use pathlib.Path(identifier).name or os.path.basename(identifier) and check for unsafe values like . or ...

f'({size} bytes > cap {self.artifact_max_bytes})'
)
continue
dest = tmp_dir / 'artifacts' / artifact.name

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The artifact.name is an externally-sourced identifier from the wandb API. To prevent path traversal vulnerabilities, sanitize it using Path(artifact.name).name and check for unsafe values like . or .. before constructing the download destination path.

            safe_name = Path(artifact.name).name
            if not safe_name or safe_name in ('.', '..'):
                logger.warning(f'{tag}: unsafe artifact name {artifact.name!r}, skipping')
                continue
            dest = tmp_dir / 'artifacts' / safe_name
References
  1. Sanitize externally-sourced identifiers before using them to construct file paths to prevent path traversal vulnerabilities. Use pathlib.Path(identifier).name or os.path.basename(identifier) and check for unsafe values like . or ...

Comment thread pluto/migrate/loader.py Outdated
Comment on lines +262 to +263
path = run_dir / (row['file_value'] or '')
if not row['file_value'] or not path.exists():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The row['file_value'] is read from the staged parquet files, which could be manipulated or contain unsafe paths. To prevent path traversal vulnerabilities, sanitize it using Path(file_value).name and check for unsafe values like . or .. before checking for its existence or loading it.

Suggested change
path = run_dir / (row['file_value'] or '')
if not row['file_value'] or not path.exists():
file_value = row['file_value'] or ''
safe_file_value = Path(file_value).name
if not safe_file_value or safe_file_value in ('.', '..') or safe_file_value != file_value:
logger.warning(f'{tag}: unsafe file path {file_value!r}, skipping')
return
path = run_dir / safe_file_value
if not path.exists():
References
  1. Sanitize externally-sourced identifiers before using them to construct file paths to prevent path traversal vulnerabilities. Use pathlib.Path(identifier).name or os.path.basename(identifier) and check for unsafe values like . or ...

Comment on lines +50 to +53
def parse_iso_ms(value: Optional[str]) -> Optional[int]:
"""Parse an ISO-8601 string to epoch milliseconds (UTC assumed if naive)."""
if not value:
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The parse_iso_ms function assumes value is always a string. If the wandb API returns a non-string type (such as an integer or a datetime object), calling .replace will raise an AttributeError and crash the export. Add a defensive check to ensure value is a string.

Suggested change
def parse_iso_ms(value: Optional[str]) -> Optional[int]:
"""Parse an ISO-8601 string to epoch milliseconds (UTC assumed if naive)."""
if not value:
return None
def parse_iso_ms(value: Optional[str]) -> Optional[int]:
"""Parse an ISO-8601 string to epoch milliseconds (UTC assumed if naive)."""
if not isinstance(value, str):
return None
try:

Comment on lines +152 to +156
def _export_run(self, run: Any, run_dir: Path) -> None:
tmp_dir = run_dir.with_name(run_dir.name + '.tmp')
if tmp_dir.exists():
shutil.rmtree(tmp_dir) # leftovers from an interrupted export
tmp_dir.mkdir(parents=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If an exception occurs during _export_run (e.g., during history scanning or file downloading), the partial .tmp directory is left on disk. Wrap the export logic in a try-except block to clean up tmp_dir on failure so orphaned temporary directories do not accumulate.

    def _export_run(self, run: Any, run_dir: Path) -> None:
        tmp_dir = run_dir.with_name(run_dir.name + '.tmp')
        if tmp_dir.exists():
            shutil.rmtree(tmp_dir)  # leftovers from an interrupted export
        tmp_dir.mkdir(parents=True)

        try:
            created_ms = parse_iso_ms(getattr(run, 'created_at', None))
            with PartWriter(tmp_dir) as writer:
                self._write_run_json(run, tmp_dir, created_ms)
                self._export_history(run, writer)
                if self.include_system:
                    self._export_system_metrics(run, writer)
                files_dir = tmp_dir / 'files'
                if self.include_files or self.include_console:
                    self._download_files(run, files_dir)
                if self.include_console:
                    self._export_console(run, writer, files_dir, created_ms)
                if self.include_artifacts:
                    self._export_artifacts(run, writer, tmp_dir)

            mark_run_exported(tmp_dir, {'rows': writer.rows_written})
            if run_dir.exists():
                shutil.rmtree(run_dir)
            os.rename(tmp_dir, run_dir)
        except Exception:
            if tmp_dir.exists():
                shutil.rmtree(tmp_dir)
            raise

return
base = self._row_base(run)
fallback_ms = created_ms or 0
with open(output_log, errors='replace') as f:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Opening files without specifying an explicit encoding is platform-dependent and can lead to UnicodeDecodeError or corrupt characters on platforms where the default encoding is not UTF-8 (such as Windows). Specify encoding='utf-8' explicitly.

Suggested change
with open(output_log, errors='replace') as f:
with open(output_log, encoding='utf-8', errors='replace') as f:

Comment thread pluto/migrate/state.py
def write_json_atomic(path: Union[str, Path], obj: Any) -> None:
path = Path(path)
tmp = path.with_name(path.name + '.tmp')
with open(tmp, 'w') as f:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Opening files without specifying an explicit encoding is platform-dependent and can lead to UnicodeDecodeError or corrupt characters on platforms where the default encoding is not UTF-8 (such as Windows). Specify encoding='utf-8' explicitly.

Suggested change
with open(tmp, 'w') as f:
with open(tmp, 'w', encoding='utf-8') as f:

Comment thread pluto/migrate/state.py


def read_json(path: Union[str, Path]) -> Any:
with open(path) as f:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Opening files without specifying an explicit encoding is platform-dependent and can lead to UnicodeDecodeError or corrupt characters on platforms where the default encoding is not UTF-8 (such as Windows). Specify encoding='utf-8' explicitly.

Suggested change
with open(path) as f:
with open(path, encoding='utf-8') as f:

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ubuntu and others added 11 commits August 1, 2026 00:54
…ity guard)

pluto.log({"phase": "warmup"}) now works natively, mirroring the wandb
migration: a bare string value is routed to the string-series data path
(mlop_data, dataType="string-series", raw value, logName registered as DATA)
instead of being silently dropped. Numeric/media/data paths are unchanged.

- op.py: route str values in _process_log_item_sync; register string keys under
  the DATA log type; new _enqueue_string_series_sync helper.
- sync/process.py: send string-series `data` raw (not JSON-wrapped), matching
  the migration wire and the reader; widen enqueue_data's data_dict type.

No cardinality/distinct-value guard: every string value is kept (no data loss),
regardless of how many distinct values the series has. The only value not sent
is a single point over 200 chars (a stray blob, not a state label), warned once
per key. The migration loader's cardinality guard is likewise removed.

Verified end-to-end against a local stack: phase -> string-series timeline,
an all-distinct 12-point series keeps all 12 points.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a native sweep API mirroring wandb's, single-machine (no launcher):

- pluto.sweep(config) -> id: validate a wandb-shaped search space
  ({method, metric, parameters}); grid + random supported (bayes rejected with
  a clear message, planned via optuna). Stored in-process + on disk.
- pluto.agent(sweep_id, fn, count): client-side "brain" enumerates (grid) or
  samples (random) the space and runs fn once per combination. The sampled
  hyperparameters are injected into each run's config and the run is tagged
  sweep:<id> (init.py hook), so runs group under their sweep — exactly the data
  model a sweep dashboard needs. Auto-finishes runs the fn leaves open.

Migration: the exporter now captures a run's wandb sweep (id/name/search-space
config) into the manifest instead of flagging it dropped; the loader tags the
run sweep:<id> and stores the sweep under config.wandb.sweep. Native and
migrated sweeps converge on the same tag+config model.

Verified end-to-end against a local stack: a 2x2 grid produces 4 tagged runs
with their combos; a real wandb sweep run migrates tagged with its search space.
Adds tests/test_sweep.py; migrate tests updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resume (grid + random), no backend endpoint needed — the SDK lists a sweep's
COMPLETED runs via pluto.query.list_runs(tags=["sweep:<id>"]):
- grid: skip combinations whose run already completed.
- random: run count - (already done).
Best-effort: if the query fails (offline / project not created), the agent just
runs everything, so a fresh sweep is never blocked.

Bayesian search (method="bayes") via optuna (optional dep, pluto[sweep]):
- agent asks optuna for each next combination, runs it, and tells optuna the
  objective (the run's final metric value), learning as it goes.
- direction from metric.goal (minimize/maximize); requires metric + count.
- resume seeds the study from completed runs' (params -> objective) so a
  restarted bayes search keeps learning; also caps at the total count.

Supporting change: Op caches the latest numeric value per key
(op._latest_metrics), and pluto.init hands the sweep run's Op back to the agent,
so the objective is read directly (pluto.ops is unreliable here — finish()
mutates it and object ids get reused).

Verified end-to-end on a local stack: grid resume runs only the remaining
combinations; a bayes search over (x-0.7)^2 converges near x=0.7. Adds bayes +
resume tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A native pluto.sweep() previously put only the sampled combo + the sweep:<id>
tag on each run, so the server never saw the sweep's method / objective / search
space and had to infer them. The agent now also stamps the declared spec onto
each run's config as `config.sweep` = {id, method, metric, parameters} — the
same shape migrated sweeps carry in `config.wandb.sweep`. The sweep dashboard
can now read the real declaration (correct optimize goal, the actual search
space, grid/random/bayes) instead of guessing, without any backend endpoint.

Implementation: agent() sets a module-level `_active_declared` for the run and
init() stamps it alongside the combo; cleared in a finally (covers the bayes
early-return). Adds a test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion

Adds an `annotations` field to the file-upload path (mirrors `caption`) that
carries a wandb-shape {boxes, masks} JSON string, forwarded verbatim to the
server (mlop_files.annotations) as the render side expects.

- pluto.Image now takes `boxes` (native, {layer: {box_data, class_labels}}) and
  `annotations` (a ready JSON blob, used by migration). Boxes default to
  domain="pixel" so coords aren't misread as 0-1 fractions.
- Plumbing: File._annotations -> op._enqueue_file_sync -> SyncManager.enqueue_file
  -> store (file_uploads.annotations column, v3 additive migration) ->
  upload_files_batch (only sent when set, like caption).
- Migration: the exporter stages the wandb box/mask refs in a new parquet
  `annotation_value` column (was: self._skipped('image-annotations')); the loader
  resolves the .boxes2D.json sidecar into the image's annotations. Coverage now
  reports `image-boxes` migrated.

Verified end-to-end into the sync DB for both native (boxes with domain=pixel)
and migration (real run's boxes resolved to {box_data, class_labels}); the field
name matches the ingest's serde `annotations` exactly.

Masks (a separate PNG to re-upload + hide from the media grid) are still
deferred — flagged `image-masks`. Adds exporter + loader tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes image annotations. Each mask layer becomes a PNG uploaded in the
image's log group with fileType "mask" (the convention the render side hides
from the media grid and resolves by fileName), referenced from the image's
annotations as {fileName, class_labels?}.

- pluto.Image now takes `masks`: native {layer: {mask_data: <HxW class-id
  array>, class_labels}} is encoded to a PNG with the class id in the red
  channel (what the renderer reads); migration {layer: {path}} forwards wandb's
  own mask.png. Mask PNGs to upload alongside are collected in
  `_annotation_files`.
- op._enqueue_file_sync uploads those sub-files in the same log group and honors
  File._upload_file_type; the sync payload sends fileType "mask" for them
  (reuses the file_type field — no new column).
- Migration: masks now migrate (was flagged image-masks); the loader resolves
  the staged .mask.png ref to a path and hands it to pluto.Image.

Verified end-to-end into the sync DB: native (numpy → red-channel PNG) and
migration (real run) both produce an image row with boxes+masks annotations and
a sibling mask row with fileType=mask whose fileName matches the reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sweep commits added optuna to pyproject.toml (optional dep, dev dep,
and the [sweep] extra) but never regenerated poetry.lock, leaving a stale
content-hash. CI's `poetry install` refuses an out-of-sync lock, so every
job failed at the "Install dependencies" step (format, mypy, tests,
api-docs, contract-test — all 9).

Re-locked with poetry 2.1.1 (matches CI). Adds only optuna + its
transitive deps (alembic, colorlog, greenlet, mako, sqlalchemy); no other
package versions change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wandb keeps two copies of a media table; the exporter stages the lossy
run-files copy, so image cells load as the literal text "Image" rather
than pictures. Pin that current behavior: the table migrates (not
dropped, no crash) and the cell images arrive as a separate, unlinked
artifact. This test should flip to assert real image cells when we wire
cell refs to uploaded images.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…annotations

Two regressions from the sweep and image-annotation commits, surfaced once
CI's install step started passing:

- _log_via_sync() unconditionally did self._latest_metrics.update(metrics),
  but call sites that build a bare Op via Op.__new__ (log-resilience unit
  tests) skip __init__ where it's initialized -> AttributeError. Logging is
  best-effort and must never crash, so lazy-init the cache at the use site
  (the sweep reader already uses getattr defensively).

- pluto.Image gained boxes/masks/annotations and the public API gained
  sweep/agent, leaving docs-api/media.mdx and meta.json stale (api-docs
  --check failed). Regenerated with griffe 2.0.2 (matches the lock).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A wandb.Table with media columns migrates degraded: we stage wandb's
lossy run-files table copy (media cells are text placeholders like
"Image"), and the cell media arrives only as the sibling run_table
artifact, unlinked from the table. Previously this happened silently.

The exporter now detects media columns by reading the downloaded table
artifact's column_types for media *-file wb_types, and emits a
table-media-cell coverage flag + warning (trips --strict). The table and
its scalar columns still migrate; only the in-cell media is flagged as
lost, mirroring the artifact-versioning partial-migration pattern.

Verified against a real seeded media table (NOT migrated: 1
table-media-cell) and with unit tests for media vs plain tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lags

Add export-side coverage for the two skip paths not exercised by the
seeded fixtures: the generic unsupported(<type>) branch (bokeh/joined-
table/... media types the exporter can't stage) and string-series-too-long
(a per-step string over the 200-char cap). FakeRun gains a history_rows
override so a test can drive scan_history directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ryanhayame

ryanhayame commented Aug 5, 2026

Copy link
Copy Markdown

wandb → Pluto migration — summary

pluto migrate wandb brings historical wandb runs into Pluto in two phases: export (wandb cloud → on-disk staging) and load (staging → Pluto via the public client). all runs both. Each wandb project maps to a same-named Pluto project.

The render half is server-private#559 — it draws what this PR imports (custom charts, media viewers, string metrics, sweeps, image overlays). Everything below is verified end-to-end against real migrated data. Native SDK users (pluto.log, pluto.sweep, pluto.Image) get the same features on the same code path.

Screenshots: every row below is the same seeded data captured in wandb (cloud) and Pluto (render branch #559 on localhost) — apples-to-apples. Visual features (boxes/masks, Plotly/3D, sweeps) are shown across multiple areas (Charts + Files/list).

Commands

Goal Command
Migrate one project, end-to-end pluto migrate wandb all --entity E --project P
Several specific projects (repeat --project) pluto migrate wandb all --entity E --project P --project B
A whole account (every project) pluto migrate wandb all --entity E
Everything except some projects pluto migrate wandb all --entity E --exclude foo
Only specific runs (optional subset) pluto migrate wandb all --entity E --project P --run-id abc123 --run-id def456
Two-phase — stage to disk… pluto migrate wandb export --entity E --output ./stage
…then load from disk pluto migrate wandb load --input ./stage
Go faster (more parallelism) … --workers 16
Free staged disk as it loads … --cleanup
Preview a load, write nothing pluto migrate wandb load --input ./stage --dry-run
Fail loudly if anything can't migrate … --strict

Knobs

Flag Phase Default Meaning
--project all all repeatable; scope to specific project(s)
--exclude all none skip project(s) during an all-projects sweep
--workers all 4 file-download threads per run and projects migrated concurrently. Each project pins its history + upload queue in RAM (~2-4 GB), so raise only with headroom.
--cleanup load / all off delete each run's staged files once confirmed loaded
--dest-project load / all source name rename the destination (single project only)
--strict export off non-zero exit if any data couldn't be migrated
--dry-run load off report what would load; write nothing
--run-id export / load / all all runs repeatable; migrate only these run id(s). Omit (the default) to migrate the whole project — you never need to list runs
--after / --before export date filter on which runs to export

What migrates

Each row is the same seeded data in wandb and Pluto — screenshots, not links. Visual features are shown across multiple areas. Nothing crashes on any migrated run.

What migrates wandb Pluto
Scalar metrics — int/float series, original step + timestamp; NaN/±Inf preserved
Histograms — real bin edges rebuilt from packedBins; ridgeline/heatmap section
Images — single and list-logged (order preserved via sampleIndex)
Video — incl. list-logged, step slider
Audio — incl. list-logged
Tables — grid in Charts + raw JSON in /files; bool/unicode columns Charts

/files
Run metadata · env · timestamps — name/notes/tags, state, config, git/OS/Python, real Duration
Console logs — stdout/stderr; unicode, ansi, stderr preserved
Artifact fileswandb.Artifact files → Pluto Artifacts
HTML (wandb.Html) — sandboxed iframe preview in /files
Plotly · matplotlib · 3D point clouds — arrive as artifact JSON, render as interactive viewers. mpl is stored as a Plotly figure. Plotly

3D cloud
Sweepssweep:<id> tag + search space → a Sweeps tab per project (parallel coords, parameter importance, best run). Same wandb sweep cvxvtpim on both sides. Native pluto.sweep() identical. Sweeps tab

Detail
String / status history — non-numeric per-step series (phase: warmup→train→…) → step chart (categories on Y). wandb can't chart it; native pluto.log too.
Custom charts (wandb.plot.*) — presets render as Vega panels (wandb's own spec, fetched + frozen); backing table migrates too.
Image boxes & maskswandb.Image(boxes=, masks=) overlays migrate and both render. Masks need a colour key (class_labels) that wandb hides in the run config, not the mask file — the exporter now recovers it, so migrated masks paint the same as native pluto.Image(masks=). Dashboard

/files

What does NOT fully migrate

Each of these warns you during migration (and stops it in --strict mode) — nothing is dropped silently. They all need a fix on the export side plus a re-run to work. (There used to be a second list — things that migrated fine and only needed the frontend to draw them — but #559 cleared it out.)

What can't fully migrate What's missing Warning it prints Effort
Joined / partitioned tables the whole table (plain tables are fine) unsupported(<type>) medium-high
Artifact version history & aliases the version graph (the files themselves do migrate) artifact-versioning high
"Which run used which artifact" that link between runs and artifacts artifact-input-lineage high
Bokeh / molecule files the file itself never comes over unsupported(<type>) medium

Masks: one deployment caveat (CORS). Masks now render (see above — the exporter recovers class_labels from the run config). But colouring a mask happens in your browser, which has to read the mask file's pixels — and browsers only allow that if the storage server sends "CORS" headers. MinIO (the local default) sends them; AWS S3 doesn't unless you set it up, and our Terraform doesn't — so on a self-hosted AWS deployment, masks won't display until that's added. Photos and boxes are fine either way.

Tables with images inside their cells migrate, but the images come loose. wandb keeps two copies of such a table: a full one (with real image links) and a stripped one where each image cell is just the word "Image". We currently grab the stripped copy, so the table shows "Image" as text where the pictures should be. The pictures themselves do migrate — but as a separate, unlinked file collection in the Files tab. It's no longer silent: migration now spots these tables and warns you (table-media-cell, stops --strict). Nothing is lost from disk — what's missing is the link from cell to picture. Fix: grab the full table copy and wire each cell to its uploaded image (us) + show images inside table cells (frontend).

These aren't real gaps: artifact-over-size-cap and media-file(--no-files) only happen when you ask for them (you set a size limit, or pass --no-files); file-download-failed is a temporary network error that's already retried automatically. Deliberately not read (out of scope): wandb Reports & saved views, the Model Registry, and internal _-prefixed keys. System metrics do migrate and we verified it — a stats-enabled test run captured CPU/memory samples and they landed in the database.

Still to do: artifact version-history graph (frontend); a custom-chart dashboard widget (frontend — they already render on the run and all-runs pages); and hand-drawn custom charts (custom-chart-unsupported — wandb won't hand over the drawing recipe, so Pluto shows a link to the underlying data table, which does migrate).

Sweeps — how Pluto differs from wandb

In Pluto a sweep is just "all the runs tagged with that sweep's id" — no new database tables. Because of that, Pluto works out a sweep's state (running / finished / stopped early) from its runs, instead of storing one like wandb does. A few side effects: a crashed run keeps a sweep looking "running" until a background cleanup notices (~30 min); "stopped early" only applies to grid sweeps; and the run count is "how many were attempted" (a 6-run grid where 4 crashed still says "6/6", with the failures shown next to it). Parameter importance (which knob mattered most) uses the same math as wandb — the ranking matches, though the exact bar lengths won't, because wandb keeps its random seed secret. Not built: starting / pausing / stopping / resuming a sweep from the UI — all of which would need a live sweep controller running.

Benchmarks

A 740-run test project covering every data type and edge case (NaN/±Inf/null, failed & crashed runs, all sweep kinds, mixed-dtype and image-cell tables, boxes/masks, all artifact flavours, molecule/bokeh/joined-table, galleries, system metrics) was seeded to wandb, migrated into Pluto (with the #559 frontend), and checked three ways — timing, data loss (counted in the DB), and rendering.

  • Coverage: 11 data kinds migrate fully (metrics, media, boxes/masks, histograms, system metrics, console, string metrics, sweeps, custom charts, artifact files). 10 can't fully migrate but warn — a named flag, and --strict stops the run — instead of dropping silently (image-cell tables, artifact version history / lineage, oversized artifacts, --no-files media, over-long strings, hand-drawn charts, molecule/bokeh/joined-table).
  • Data integrity — 0 loss: 740/740 runs landed; ClickHouse row counts match the export exactly (5,320 = 5,320) across load / all / cleanup; boxes, mixed-dtype tables, and all 80 sweeps render.

Performance (--workers 4):

localhost · 740 · batched dev/prod · 771 · single call
export 27m 26m
load 20m 28m
all (export+load) 32m 30m
  • all overlaps export+load (~⅓ faster than separate). Export is wandb-download-bound; load is slower on dev (public internet vs loopback).
  • The single whole-project call against prod is the headline — no batching needed now the process leak is fixed (live processes stay ~2–4, RAM flat; 771/771, 0 failed). --cleanup reclaims staged disk (1.3 G → 128 K).
  • --workers only splits work across projects, so it barely helps on one project; it pays off for many file-heavy projects.
  • (One machine, one run each — treat minutes loosely; numbers predate the ~+3% status read-back added below.)

Resilience & recovery

Interruptions (network blips, redeploys, killed processes) are survivable — re-run the same command and it converges. Three mechanisms: crash-safe staging (parquet renamed only after a completion sentinel; load buffers to SQLite before upload), idempotent resume (loaded_runs.json skips done / retries failed; a re-load of an existing run skips replay instead of duplicating), and retry with backoff (5× jittered).

Event What happens
Network blip retried; a persistent failure is flagged (file/artifact-download-failed), never silent; re-run picks it up
Redeploy during migration in-flight runs flagged; staging + SQLite persist → re-run resumes
Redeploy after migration no impact — data lives in the DB / ClickHouse / store, not the pods
Process killed / OOM data on disk survives; re-run resumes

Caveats: a mid-upload interruption can duplicate some media on resume (metrics dedup by timestamp; loader warns); a corrupt wandb artifact (bad manifest) can't be healed by retry — flagged and skipped.

Terminal status — always matches wandb (b1e1ddb, 3000597). Two edges could leave a run's final status wrong even with all data intact — both closed:

  • A dropped "finished" signal (transient reset) used to be swallowed silently; it now retries and surfaces a real failure.
  • A back-dated import can be false-reaped to FAILED by the server's stale-run monitor in a create-time race, after which the client's COMPLETED is silently rejected. The loader now reads each finished run's status back and heals a false reap via resume→finish (no data re-upload), bounded to 3 tries, flagging anything unconfirmed. wandb-failed runs need no check (FAILED can't be outranked).

Validated: a fresh 490-run migration hit one live false-reap, auto-healed it inline, and a status-by-status audit confirmed 490/490 correct, 0 stranded (+3% wall-clock for the read-backs). Recovery is client-side; preventing the reap is a separate server-side change, not required for correctness.

Bugbot hardening (e4eb461): the heal now checks its own finish (won't mark a run loaded when a heal is unconfirmed and unreadable); a date-filtered export excludes undated runs instead of silently including them; the empty-cache purge only reaps aged 0-byte files, so parallel projects can't delete each other's in-progress downloads.

Ubuntu and others added 2 commits August 5, 2026 20:10
… config)

Migrated segmentation masks rendered blank because they arrived without
class_labels (the id->name colour key). Root cause: wandb stores mask
class_labels in the run *config* (_wandb.value["mask/class_labels"], keyed
'<image>_wandb_delimeter_<layer>'), NOT in the mask media descriptor that
scan_history returns — and separately, the loader was dropping class_labels
from the staged mask ref (kept only the path).

Fix, both on the pluto side (no server-private change needed — the #559
frontend already paints class_labels; native masks proved it):
- exporter: recover class_labels from run config and fold them into each
  mask layer's annotation.
- loader: carry class_labels through to the uploaded mask spec.

Verified end-to-end: a wandb-imported mask now renders identically to a
native pluto.Image(masks=). Adds exporter + loader unit tests; also lands a
previously-pending artifact-download-failed export test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PR #131 summary now embeds screenshots via GitHub user-attachments, so
the committed .github/pr131/*.png copies (and loose .github screenshots) are
unused. Removes ~3.8 MB of tracked images.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ryanhayame

Copy link
Copy Markdown

@cursor review

Comment thread pluto/migrate/loader.py
Comment thread pluto/migrate/cli.py
Comment thread pluto/migrate/cli.py
Ubuntu and others added 5 commits August 5, 2026 22:24
…led restores, attempt failed runs once

Three defects in the load/all failure path (flagged in review of 0f61ca8):

1. loader: on a collision-restore where finish() throws, the run was still
   marked loaded and skipped — stranding it RUNNING server-side and skipping
   it forever. Now it's reported as failed (not marked loaded), so a later
   run retries it.

2/3. all: a run that failed one poll pass was re-attempted every subsequent
   pass (re-running identical staged data can't help and risks duplicating
   media on a mid-replay resume), and once added to the reported-failures set
   it could never be cleared. PlutoLoader now takes skip_run_ids; _all_one_project
   feeds already-failed run-ids into it, so each failure is at-most-once and the
   reported failure count stays exact.

The loader only ever sees a run once its export sentinel is written, so a load
failure is genuine (complete data) rather than "needs more time" — attempt-once
is safe; the user re-runs all/load to retry (in-progress runs resume from the
ledger).

Adds regression tests: restore-failure is reported not marked loaded,
skip_run_ids bypasses a run without attempting it, and all feeds failed
run-ids into skip_run_ids on later passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on't leak processes

The sync subprocess is a persistent daemon: its main loop only breaks on
SIGTERM or when the parent process dies. Normal-mode stop(wait=True) flushed
the run's data and returned without terminating it — so each run's subprocess
survived until the *caller* exited.

For a short-lived caller (a training script) that's harmless: the process ends
and orphan-detection reaps the subprocess. But a long-lived caller that starts
one sync process per run — a single `pluto migrate wandb load` invocation over a
whole project — accumulates one live subprocess per run. A 740-run load piled up
~584 subprocesses and drove a 32 GB host to 2 GB free (near-OOM). The localhost
benchmark only avoided this by splitting the load into many separate 50-run
invocations, whose exits reaped each batch — masking the leak rather than fixing
it.

stop(wait=True) now terminates the subprocess once its data is flushed (SIGTERM
-> bounded wait -> SIGKILL fallback), mirroring the wait=False branch. Each
subprocess's lifetime is scoped to its run, so a whole-project single-invocation
load stays flat (a 20-run load peaks at ~3 concurrent sync processes instead of
20). Adds a regression test asserting the subprocess is dead after finish().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ated)

Images logged as a list (wandb.log({k: [img, img, ...]}), wandb type
images/separated) were migrating as plain pictures — their boxes/masks were
silently dropped. The exporter downloaded the mask files but wrote
annotation_value=None for every gallery image, so the loader never linked them.

Single images were fine; only the gallery/list path missed it: it never read the
per-image all_boxes[i] / all_masks[i] arrays. Extracted a shared
_image_annotation_value() helper (used by both the single-image and gallery
paths) that folds mask class_labels back in from the run config, and the gallery
path now emits per-image annotation_value.

Verified end-to-end: re-export of a gallery run stages 12/12 images with
annotation_value (was 0/12) — boxes + masks + class_labels — and a live
re-migration lands 240 mask files where there were 0. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ate drops

A transient TCP reset on a run's final status POST (/api/runs/status/update) was
swallowed as a "shutdown signal": _try returned None with no retry and a DEBUG
log, update_status() ran with raise_on_error=False, and finish() never surfaced
it. The migration loader then marked the run "loaded", so it stayed non-terminal
server-side (UI renders it FAILED with an ever-growing duration) and a re-run
skipped it — permanently stranded. Hit ~0.6% of a 490-run migration (3 runs),
all reported as "0 failed".

Three layers of defense:
- iface: add retry_connection_errors so the terminal status POST retries dropped
  keep-alive sockets (with backoff) and raises on final failure, instead of the
  fire-and-forget shutdown shortcut (kept for heartbeat/trigger/upload spam).
  update_status() now runs raise_on_error=True + retry_connection_errors=True.
- op: _teardown records an unconfirmed terminal status on op._status_update_error
  instead of swallowing it, and no longer re-flips an already-confirmed run to
  FAILED on a later teardown hiccup.
- loader: don't mark_loaded a run whose finish wasn't confirmed — record it
  failed so the next all/load pass heals it via the RunExistsError->restore path.

Tests: connection-reset retry/raise in _try + update_status wiring; loader
routes an unconfirmed finish to failed (main + restore paths); finish() records
the error without crashing and keeps a confirmed run COMPLETED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eaps

A back-dated freshly-created import can be reaped to FAILED by the server's
stale-run monitor in a sub-second race; the client's COMPLETED is then silently
rejected (HTTP 200, no effect), so the run is stranded FAILED while the loader
reports success. The earlier b1e1ddb only catches a finish that errors/drops —
this one returns 200, so it slipped through.

After a run finishes, read its status back (GET /api/runs/details/{id}) and, on a
mismatch for a finished import, heal it by resume+finish — reopening the run to
RUNNING and finishing again, which sidesteps the terminal-precedence guard the
way a plain re-finish cannot, and replays nothing (no duplication). Bounded to
3 attempts; a still-unconfirmed run is recorded failed (a later pass retries)
rather than cached as loaded. Unreadable status degrades to best-effort (don't
fail a run over our own inability to verify). Scoped to the migration loader, so
native runs are untouched. No server change; recovery only (it does not prevent
the reap — the raceless prevention is the server-side lastActivityAt floor).

Validated end-to-end: healed the one real stranded run (ca40i2mb / MFV-372) in
migrate-final-v2 to COMPLETED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ryanhayame

Copy link
Copy Markdown

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 3000597. Configure here.

Comment thread pluto/migrate/loader.py
Comment thread pluto/migrate/wandb_export.py
Comment thread pluto/migrate/wandb_export.py
…ter, purge race

- loader: `_verify_and_heal` now checks the heal's own finish
  (`op._status_update_error`) and no longer best-effort-marks a run loaded when a
  heal was left unconfirmed and the status can't be read back — it reports failed
  so a re-run retries (matches the main/restore paths). (High)
- wandb_export: exclude runs with an unparseable `created_at` from a date-filtered
  export instead of silently including them past the requested window. (Medium)
- wandb_export: the empty-cache purge only reaps 0-byte parquets older than
  `_EMPTY_CACHE_STALE_SECONDS`, so parallel projects sharing the wandb cache under
  `--workers>1` don't delete each other's in-progress (briefly 0-byte) downloads.
  (Medium)

Tests: heal-unconfirmed -> failed (not loaded); undated run excluded when a date
filter is set and kept when not; purge spares a fresh 0-byte file and reaps a
stale one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ryanhayame

ryanhayame commented Aug 12, 2026

Copy link
Copy Markdown
screen-recording-2026-08-12-at-42317-pm_fsKYFg77.mov

wandb 9 custom charts vs pluto 10 custom charts:
pluto includes broken wandb custom charts. wandb doesnt. weirdly, histograms are bugged on wandb. need to search for "cu"

wandb 3 charts vs pluto 5 metrics:
pluto includes string metrics. wandb doesnt.

wandb 12 media vs pluto 76 media:
pluto includes tons of extra files. wandb doesnt. pluto download/export/fullscreen of masking + boxes is better.

wandb 13 table vs pluto 12 tables:
wandb supports joined tables and image tables. pluto half supports them. wandb counts joined tables as a table while pluto keeps it as an artifact, not a table. pluto does migrate image tables (they show up as tables) but does not render the image.

wandb 10 sys vs pluto 12 sys:
pluto separates sys/.disk from sys/ stuff.

@ryanhayame ryanhayame left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good job whoever wrote this

@ryanhayame

Copy link
Copy Markdown

forgot video of sweep stuff:

Screen.Recording.2026-08-12.at.5.00.12.PM.mov

@ryanhayame

ryanhayame commented Aug 13, 2026

Copy link
Copy Markdown

New native SDK additions in this PR (not in the docs yet)

Besides the wandb migration, this PR adds three things you can log natively. Everything else in the docs — init / log / Image / Audio / Video / Text / Histogram / Table / Artifact / watch — already existed and is unchanged.

Setup: pluto login <token> (or PLUTO_API_KEY), then run = pluto.init(project="demo").

1. String metrics — log() now accepts strings

A string value becomes a categorical string metric (a phase/state over time), stored as a string-series. Previously log() took numbers only.

run.log({"phase": "warmup"})

2. Image annotations — boxes + masks on pluto.Image

pluto.Image gained boxes / masks (wandb-shape) to overlay detections / segmentation.

run.log({"det": pluto.Image(
    img,
    boxes={"predictions": {
        "box_data": [{"position": {"minX": 10, "minY": 20, "maxX": 80, "maxY": 90},
                      "class_id": 3, "box_caption": "car", "domain": "pixel"}],
        "class_labels": {3: "car"}}},
    masks={"predictions": {"mask_data": mask_2d, "class_labels": {1: "road", 3: "car"}}},
)})

3. Sweeps — pluto.sweep + pluto.agent (brand new)

Native hyperparameter search, mirroring wandb.sweep / wandb.agent. grid / random work out of the box; bayes needs optunapip install "pluto[sweep]".

sid = pluto.sweep({
    "method": "grid",   # grid | random | bayes
    "metric": {"name": "loss", "goal": "minimize"},
    "parameters": {"lr": {"values": [0.1, 0.01, 0.001]}},
})

def train():
    r = pluto.init(project="demo")   # sampled hyperparams land in r.config
    r.log({"loss": r.config["lr"] * 0.5})
    r.finish()

pluto.agent(sid, train)              # grid runs all combos; random / bayes need count=N

Not new (already documented): init / log (numeric) / finish, Image / Audio / Video / Text, Histogram / Table / Artifact, watch(model). Custom charts have no native API — they only arrive via a wandb migration (config.wandb.custom_charts).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants