weaveforge 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- weaveforge/__init__.py +72 -0
- weaveforge/cli.py +73 -0
- weaveforge/config.py +68 -0
- weaveforge/container.py +89 -0
- weaveforge/features/__init__.py +3 -0
- weaveforge/features/experiments/__init__.py +38 -0
- weaveforge/features/experiments/application/__init__.py +5 -0
- weaveforge/features/experiments/application/git.py +46 -0
- weaveforge/features/experiments/application/manage_experiment.py +109 -0
- weaveforge/features/experiments/application/run.py +174 -0
- weaveforge/features/experiments/domain/__init__.py +30 -0
- weaveforge/features/experiments/domain/experiment.py +101 -0
- weaveforge/features/experiments/domain/experiment_repository.py +19 -0
- weaveforge/features/experiments/domain/metric_point.py +74 -0
- weaveforge/features/experiments/domain/metric_repository.py +26 -0
- weaveforge/features/experiments/infrastructure/__init__.py +5 -0
- weaveforge/features/experiments/infrastructure/api_artifact_storage.py +29 -0
- weaveforge/features/experiments/infrastructure/api_experiment_repository.py +74 -0
- weaveforge/features/experiments/infrastructure/api_metric_repository.py +34 -0
- weaveforge/features/experiments/infrastructure/supabase_artifact_storage.py +50 -0
- weaveforge/features/experiments/infrastructure/supabase_experiment_repository.py +91 -0
- weaveforge/features/experiments/infrastructure/supabase_metric_repository.py +49 -0
- weaveforge/features/projects/__init__.py +10 -0
- weaveforge/features/projects/domain/__init__.py +3 -0
- weaveforge/features/projects/domain/project_reader.py +16 -0
- weaveforge/features/projects/infrastructure/__init__.py +1 -0
- weaveforge/features/projects/infrastructure/api_project_reader.py +14 -0
- weaveforge/features/projects/infrastructure/supabase_project_reader.py +25 -0
- weaveforge/infrastructure/api_client.py +54 -0
- weaveforge/integrations/__init__.py +13 -0
- weaveforge/integrations/_common.py +55 -0
- weaveforge/integrations/keras.py +59 -0
- weaveforge/integrations/lightning.py +81 -0
- weaveforge/py.typed +0 -0
- weaveforge/shared/__init__.py +26 -0
- weaveforge/shared/clock.py +43 -0
- weaveforge/shared/repository.py +59 -0
- weaveforge/sync/__init__.py +38 -0
- weaveforge/sync/matplotlib.py +69 -0
- weaveforge/sync/registry.py +42 -0
- weaveforge/sync/source.py +93 -0
- weaveforge/sync/tensorboard.py +64 -0
- weaveforge/sync/wandb.py +136 -0
- weaveforge/testing/__init__.py +20 -0
- weaveforge/testing/contracts.py +78 -0
- weaveforge/testing/fakes.py +21 -0
- weaveforge/testing/in_memory_experiment_repository.py +37 -0
- weaveforge/testing/in_memory_metric_repository.py +26 -0
- weaveforge/testing/memory_container.py +43 -0
- weaveforge/tracking.py +171 -0
- weaveforge-0.6.0.dist-info/METADATA +175 -0
- weaveforge-0.6.0.dist-info/RECORD +55 -0
- weaveforge-0.6.0.dist-info/WHEEL +4 -0
- weaveforge-0.6.0.dist-info/entry_points.txt +2 -0
- weaveforge-0.6.0.dist-info/licenses/LICENSE +661 -0
weaveforge/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""weaveforge — Python SDK for WeaveForge.
|
|
2
|
+
|
|
3
|
+
Push experiments, training curves, and figures into the same Supabase database
|
|
4
|
+
the PWA reads, using the decorator as the one entry point::
|
|
5
|
+
|
|
6
|
+
from weaveforge import track_experiment
|
|
7
|
+
|
|
8
|
+
@track_experiment(name="beta-vae sweep", config={"beta": 4}, sync=["tensorboard"])
|
|
9
|
+
def train(run):
|
|
10
|
+
run.log_metric("val_loss", 0.11, step=100)
|
|
11
|
+
run.log_figure(fig, name="reconstruction")
|
|
12
|
+
return {"val_loss": 0.11} # recorded as summary metrics
|
|
13
|
+
|
|
14
|
+
Mirrors the web app's modular shape (``features/<name>/...``); the Supabase
|
|
15
|
+
migrations are the shared source of truth (see ``docs/building/design.md`` §5.4).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .features.experiments import (
|
|
19
|
+
EXPERIMENT_STATUSES,
|
|
20
|
+
Experiment,
|
|
21
|
+
ExperimentStatus,
|
|
22
|
+
ExperimentValidationError,
|
|
23
|
+
ManageExperimentUseCase,
|
|
24
|
+
MetricPoint,
|
|
25
|
+
MetricSeries,
|
|
26
|
+
NewExperimentInput,
|
|
27
|
+
short_sha,
|
|
28
|
+
)
|
|
29
|
+
from .tracking import track, track_experiment
|
|
30
|
+
|
|
31
|
+
# Hatch reads this as the package version (see pyproject [tool.hatch.version]).
|
|
32
|
+
# The PyPI publish runs off the repo's vX.Y.Z tag, so this must be bumped in
|
|
33
|
+
# step with it — PyPI refuses a re-upload of a version it already has.
|
|
34
|
+
__version__ = "0.6.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Experiment",
|
|
38
|
+
"ExperimentStatus",
|
|
39
|
+
"EXPERIMENT_STATUSES",
|
|
40
|
+
"NewExperimentInput",
|
|
41
|
+
"ExperimentValidationError",
|
|
42
|
+
"ManageExperimentUseCase",
|
|
43
|
+
"MetricPoint",
|
|
44
|
+
"MetricSeries",
|
|
45
|
+
"short_sha",
|
|
46
|
+
"track",
|
|
47
|
+
"track_experiment",
|
|
48
|
+
"Run",
|
|
49
|
+
"connect",
|
|
50
|
+
"Container",
|
|
51
|
+
"Settings",
|
|
52
|
+
"__version__",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def __getattr__(name: str):
|
|
57
|
+
"""Lazily expose the connection layer so ``import weaveforge`` (and the
|
|
58
|
+
decorator) work without the ``supabase`` package installed — it's only
|
|
59
|
+
needed when you actually ``connect()``."""
|
|
60
|
+
if name == "Run":
|
|
61
|
+
from .features.experiments.application.run import Run
|
|
62
|
+
|
|
63
|
+
return Run
|
|
64
|
+
if name in ("connect", "Container"):
|
|
65
|
+
from . import container as _c
|
|
66
|
+
|
|
67
|
+
return getattr(_c, name)
|
|
68
|
+
if name == "Settings":
|
|
69
|
+
from .config import Settings
|
|
70
|
+
|
|
71
|
+
return Settings
|
|
72
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
weaveforge/cli.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""``weaveforge`` command-line interface.
|
|
2
|
+
|
|
3
|
+
A thin presentation layer over the SDK (SRP): it parses args and calls the same
|
|
4
|
+
``connect`` / ``track`` the library exposes — no business logic of its own.
|
|
5
|
+
|
|
6
|
+
weaveforge list [--project NAME]
|
|
7
|
+
weaveforge import-tb <logdir> [--name N] [--project P]
|
|
8
|
+
weaveforge import-wandb <entity/proj/id> [--name N] [--project P]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _cmd_list(args: argparse.Namespace) -> int:
|
|
21
|
+
from .container import connect
|
|
22
|
+
|
|
23
|
+
c = connect(project=args.project)
|
|
24
|
+
rows = c.experiments.list()
|
|
25
|
+
if not rows:
|
|
26
|
+
print("No experiments.")
|
|
27
|
+
return 0
|
|
28
|
+
for e in rows:
|
|
29
|
+
metrics = ", ".join(f"{k}={v}" for k, v in list(e.metrics.items())[:4])
|
|
30
|
+
print(f"{e.status:<9} {e.name}" + (f" [{metrics}]" if metrics else ""))
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _import(source_id: str, ref: str, args: argparse.Namespace) -> int:
|
|
35
|
+
from .tracking import track
|
|
36
|
+
|
|
37
|
+
name = args.name or f"{source_id} import"
|
|
38
|
+
with track(name, sync={source_id: ref}, project=args.project) as run:
|
|
39
|
+
print(f"Imported {source_id} '{ref}' into experiment {run.id}")
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
44
|
+
parser = argparse.ArgumentParser(prog="weaveforge", description=__doc__)
|
|
45
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
46
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
47
|
+
|
|
48
|
+
p_list = sub.add_parser("list", help="list experiments")
|
|
49
|
+
p_list.add_argument("--project", help="project name to scope to")
|
|
50
|
+
p_list.set_defaults(func=_cmd_list)
|
|
51
|
+
|
|
52
|
+
p_tb = sub.add_parser("import-tb", help="import TensorBoard scalars")
|
|
53
|
+
p_tb.add_argument("logdir")
|
|
54
|
+
p_tb.add_argument("--name")
|
|
55
|
+
p_tb.add_argument("--project")
|
|
56
|
+
p_tb.set_defaults(func=lambda a: _import("tensorboard", a.logdir, a))
|
|
57
|
+
|
|
58
|
+
p_wb = sub.add_parser("import-wandb", help="import a wandb run")
|
|
59
|
+
p_wb.add_argument("run_path", help="entity/project/run_id")
|
|
60
|
+
p_wb.add_argument("--name")
|
|
61
|
+
p_wb.add_argument("--project")
|
|
62
|
+
p_wb.set_defaults(func=lambda a: _import("wandb", a.run_path, a))
|
|
63
|
+
|
|
64
|
+
return parser
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
68
|
+
args = build_parser().parse_args(argv)
|
|
69
|
+
return args.func(args)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
sys.exit(main())
|
weaveforge/config.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Environment-driven configuration for the SDK.
|
|
2
|
+
|
|
3
|
+
The SDK talks to the WeaveForge web app over HTTP using a single bearer
|
|
4
|
+
token (copied from the dashboard) so users don't need to paste Supabase URL /
|
|
5
|
+
keys into their training environment.
|
|
6
|
+
|
|
7
|
+
Kept separate from ``container.py`` so wiring stays a pure function of a
|
|
8
|
+
``Settings`` object.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
_PROJECT_VARS = ("WEAVEFORGE_PROJECT_ID",)
|
|
17
|
+
_PROJECT_NAME_VARS = ("WEAVEFORGE_PROJECT",)
|
|
18
|
+
_TOKEN_VARS = ("WEAVEFORGE_TOKEN", "WEAVEFORGE_API_TOKEN")
|
|
19
|
+
_API_URL_VARS = ("WEAVEFORGE_API_URL",)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigError(RuntimeError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _first(names: tuple[str, ...]) -> str | None:
|
|
27
|
+
for name in names:
|
|
28
|
+
value = os.environ.get(name)
|
|
29
|
+
if value:
|
|
30
|
+
return value
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Settings:
|
|
36
|
+
api_url: str
|
|
37
|
+
token: str
|
|
38
|
+
#: Optional active project to scope experiments to (matches the web app's
|
|
39
|
+
#: project switcher). ``None`` = the user's default/unscoped rows.
|
|
40
|
+
project_id: str | None = None
|
|
41
|
+
#: Optional project *name*, resolved to an id at connect time when
|
|
42
|
+
#: ``project_id`` isn't given — friendlier than hunting for a UUID.
|
|
43
|
+
project_name: str | None = None
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_env(cls) -> Settings:
|
|
47
|
+
api_url = _first(_API_URL_VARS)
|
|
48
|
+
token = _first(_TOKEN_VARS)
|
|
49
|
+
missing = [
|
|
50
|
+
label
|
|
51
|
+
for label, value in (
|
|
52
|
+
("WEAVEFORGE_TOKEN", token),
|
|
53
|
+
("WEAVEFORGE_API_URL", api_url),
|
|
54
|
+
)
|
|
55
|
+
if not value
|
|
56
|
+
]
|
|
57
|
+
if missing:
|
|
58
|
+
raise ConfigError(
|
|
59
|
+
"Missing required environment variables: "
|
|
60
|
+
+ ", ".join(missing)
|
|
61
|
+
+ ". Set it to the dashboard-issued bearer token (see python/README.md)."
|
|
62
|
+
)
|
|
63
|
+
return cls(
|
|
64
|
+
api_url=api_url, # type: ignore[arg-type]
|
|
65
|
+
token=token, # type: ignore[arg-type]
|
|
66
|
+
project_id=_first(_PROJECT_VARS),
|
|
67
|
+
project_name=_first(_PROJECT_NAME_VARS),
|
|
68
|
+
)
|
weaveforge/container.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Composition root.
|
|
2
|
+
|
|
3
|
+
The ONE place that knows concrete implementations and wires them to the
|
|
4
|
+
interfaces the rest of the SDK depends on (Dependency Inversion — the Python
|
|
5
|
+
mirror of ``apps/web/src/bootstrap.ts``).
|
|
6
|
+
|
|
7
|
+
The SDK authenticates with a single bearer token and talks to the web app's
|
|
8
|
+
`/api/sdk/*` endpoints (which apply RLS using the user's session token). This
|
|
9
|
+
keeps Supabase URL/keys out of training environments.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from .config import Settings
|
|
17
|
+
from .features.experiments.application.manage_experiment import ManageExperimentUseCase
|
|
18
|
+
from .features.experiments.infrastructure.api_artifact_storage import ApiArtifactStorage
|
|
19
|
+
from .features.experiments.infrastructure.api_experiment_repository import ApiExperimentRepository
|
|
20
|
+
from .features.experiments.infrastructure.api_metric_repository import ApiMetricRepository
|
|
21
|
+
from .features.projects.infrastructure.api_project_reader import ApiProjectReader
|
|
22
|
+
from .infrastructure.api_client import ApiClient
|
|
23
|
+
from .shared.clock import SystemClock, UuidGenerator
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Container:
|
|
28
|
+
"""Wired dependencies. Everything downstream depends on the interfaces these
|
|
29
|
+
implement, never on Supabase directly."""
|
|
30
|
+
|
|
31
|
+
user_id: str
|
|
32
|
+
project_id: str | None
|
|
33
|
+
experiments: ApiExperimentRepository
|
|
34
|
+
metrics: ApiMetricRepository
|
|
35
|
+
artifacts: ApiArtifactStorage
|
|
36
|
+
manage_experiment: ManageExperimentUseCase
|
|
37
|
+
projects: ApiProjectReader
|
|
38
|
+
api: ApiClient
|
|
39
|
+
#: Kept so a long run can re-authenticate if the token rotates.
|
|
40
|
+
_settings: Settings
|
|
41
|
+
|
|
42
|
+
def resolve_project(self, name: str) -> str | None:
|
|
43
|
+
"""Look up a project id by name (RLS-scoped to you)."""
|
|
44
|
+
return self.projects.id_by_name(name)
|
|
45
|
+
|
|
46
|
+
def reauth(self) -> None:
|
|
47
|
+
"""No-op for bearer-token auth. If you rotate the token, create a new
|
|
48
|
+
Container via connect()."""
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def connect(settings: Settings | None = None, project: str | None = None) -> Container:
|
|
53
|
+
"""Build the container. ``project`` (a name) overrides the configured
|
|
54
|
+
project for this connection."""
|
|
55
|
+
settings = settings or Settings.from_env()
|
|
56
|
+
api = ApiClient(settings.api_url, settings.token)
|
|
57
|
+
who = api.get("/api/sdk/whoami")
|
|
58
|
+
user_id = who.get("userId")
|
|
59
|
+
if not user_id:
|
|
60
|
+
raise RuntimeError("Token rejected by server (no userId).")
|
|
61
|
+
|
|
62
|
+
projects = ApiProjectReader(api)
|
|
63
|
+
project_name = project or settings.project_name
|
|
64
|
+
project_id = settings.project_id if project is None else None
|
|
65
|
+
if project_id is None and project_name:
|
|
66
|
+
project_id = projects.id_by_name(project_name)
|
|
67
|
+
if project_id is None:
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
f'No project named "{project_name}" found for this account. '
|
|
70
|
+
"Create it in the dashboard, or set WEAVEFORGE_PROJECT_ID."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
experiments = ApiExperimentRepository(api, project_id)
|
|
74
|
+
metrics = ApiMetricRepository(api)
|
|
75
|
+
artifacts = ApiArtifactStorage(api)
|
|
76
|
+
manage = ManageExperimentUseCase(
|
|
77
|
+
experiments, SystemClock(), UuidGenerator(), metrics
|
|
78
|
+
)
|
|
79
|
+
return Container(
|
|
80
|
+
user_id=user_id,
|
|
81
|
+
project_id=project_id,
|
|
82
|
+
experiments=experiments,
|
|
83
|
+
metrics=metrics,
|
|
84
|
+
artifacts=artifacts,
|
|
85
|
+
manage_experiment=manage,
|
|
86
|
+
projects=projects,
|
|
87
|
+
api=api,
|
|
88
|
+
_settings=settings,
|
|
89
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""experiments — the public API of the experiments feature module.
|
|
2
|
+
|
|
3
|
+
Others import from here, never from the internals (``docs/building/design.md`` §3.1).
|
|
4
|
+
The Supabase adapters in ``infrastructure`` are intentionally *not* re-exported:
|
|
5
|
+
they are wired only at the composition root (``weaveforge.container``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .application import ManageExperimentUseCase
|
|
9
|
+
from .domain import (
|
|
10
|
+
EXPERIMENT_STATUSES,
|
|
11
|
+
Experiment,
|
|
12
|
+
ExperimentFilter,
|
|
13
|
+
ExperimentStatus,
|
|
14
|
+
ExperimentValidationError,
|
|
15
|
+
IExperimentRepository,
|
|
16
|
+
IMetricRepository,
|
|
17
|
+
MetricPoint,
|
|
18
|
+
MetricSeries,
|
|
19
|
+
NewExperimentInput,
|
|
20
|
+
create_experiment,
|
|
21
|
+
short_sha,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Experiment",
|
|
26
|
+
"ExperimentStatus",
|
|
27
|
+
"EXPERIMENT_STATUSES",
|
|
28
|
+
"NewExperimentInput",
|
|
29
|
+
"ExperimentFilter",
|
|
30
|
+
"ExperimentValidationError",
|
|
31
|
+
"create_experiment",
|
|
32
|
+
"short_sha",
|
|
33
|
+
"IExperimentRepository",
|
|
34
|
+
"IMetricRepository",
|
|
35
|
+
"MetricPoint",
|
|
36
|
+
"MetricSeries",
|
|
37
|
+
"ManageExperimentUseCase",
|
|
38
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Best-effort capture of the local git state, so a run is automatically pinned
|
|
2
|
+
to a repo / branch / commit (the whole point of the experiments feature). Never
|
|
3
|
+
raises: outside a repo, or with git absent, every field is just ``None``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _git(*args: str, cwd: str | None = None) -> str | None:
|
|
12
|
+
try:
|
|
13
|
+
out = subprocess.run(
|
|
14
|
+
["git", *args],
|
|
15
|
+
cwd=cwd,
|
|
16
|
+
capture_output=True,
|
|
17
|
+
text=True,
|
|
18
|
+
timeout=5,
|
|
19
|
+
check=True,
|
|
20
|
+
)
|
|
21
|
+
return out.stdout.strip() or None
|
|
22
|
+
except (subprocess.SubprocessError, OSError):
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_remote(url: str | None) -> str | None:
|
|
27
|
+
"""Turn an ssh/`.git` remote into a browsable https URL (matches the web
|
|
28
|
+
app's commit-link expectations)."""
|
|
29
|
+
if not url:
|
|
30
|
+
return None
|
|
31
|
+
url = url.strip()
|
|
32
|
+
if url.startswith("git@") and ":" in url:
|
|
33
|
+
host, path = url[4:].split(":", 1)
|
|
34
|
+
url = f"https://{host}/{path}"
|
|
35
|
+
if url.endswith(".git"):
|
|
36
|
+
url = url[:-4]
|
|
37
|
+
return url
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def capture_git_state(cwd: str | None = None) -> dict[str, str | None]:
|
|
41
|
+
branch = _git("rev-parse", "--abbrev-ref", "HEAD", cwd=cwd)
|
|
42
|
+
return {
|
|
43
|
+
"repo_url": _normalize_remote(_git("config", "--get", "remote.origin.url", cwd=cwd)),
|
|
44
|
+
"commit_sha": _git("rev-parse", "HEAD", cwd=cwd),
|
|
45
|
+
"branch": None if branch == "HEAD" else branch,
|
|
46
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Use-case for experiments. Orchestration only (DIP): add, change status
|
|
2
|
+
(stamping started/finished timestamps), record summary metrics, and append
|
|
3
|
+
step-indexed history. Port of ``manage-experiment.use-case.ts``, extended with
|
|
4
|
+
``record_history`` for the metric-curve table (0016).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ....shared.clock import Clock, IdGenerator
|
|
13
|
+
from ..domain.experiment import (
|
|
14
|
+
Experiment,
|
|
15
|
+
ExperimentStatus,
|
|
16
|
+
ExperimentValidationError,
|
|
17
|
+
NewExperimentInput,
|
|
18
|
+
create_experiment,
|
|
19
|
+
)
|
|
20
|
+
from ..domain.experiment_repository import IExperimentRepository
|
|
21
|
+
from ..domain.metric_point import MetricPoint
|
|
22
|
+
from ..domain.metric_repository import IMetricRepository
|
|
23
|
+
|
|
24
|
+
_TERMINAL: tuple[ExperimentStatus, ...] = ("done", "failed", "abandoned")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ManageExperimentUseCase:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
repository: IExperimentRepository,
|
|
31
|
+
clock: Clock,
|
|
32
|
+
ids: IdGenerator,
|
|
33
|
+
metrics: IMetricRepository | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._repo = repository
|
|
36
|
+
self._clock = clock
|
|
37
|
+
self._ids = ids
|
|
38
|
+
self._metrics = metrics
|
|
39
|
+
|
|
40
|
+
def add(self, data: NewExperimentInput) -> Experiment:
|
|
41
|
+
exp = create_experiment(data, clock=self._clock, ids=self._ids)
|
|
42
|
+
self._repo.save(exp)
|
|
43
|
+
return exp
|
|
44
|
+
|
|
45
|
+
def set_status(
|
|
46
|
+
self,
|
|
47
|
+
id: str,
|
|
48
|
+
status: ExperimentStatus,
|
|
49
|
+
*,
|
|
50
|
+
existing: Experiment | None = None,
|
|
51
|
+
) -> Experiment:
|
|
52
|
+
def change(e: Experiment) -> Experiment:
|
|
53
|
+
now = self._clock.now_iso()
|
|
54
|
+
e.status = status
|
|
55
|
+
if status == "running" and not e.started_at:
|
|
56
|
+
e.started_at = now
|
|
57
|
+
if status in _TERMINAL:
|
|
58
|
+
e.finished_at = now
|
|
59
|
+
return e
|
|
60
|
+
|
|
61
|
+
return self._mutate(id, change, existing=existing)
|
|
62
|
+
|
|
63
|
+
def record_metrics(
|
|
64
|
+
self,
|
|
65
|
+
id: str,
|
|
66
|
+
metrics: dict[str, Any],
|
|
67
|
+
*,
|
|
68
|
+
existing: Experiment | None = None,
|
|
69
|
+
) -> Experiment:
|
|
70
|
+
def change(e: Experiment) -> Experiment:
|
|
71
|
+
e.metrics = {**e.metrics, **metrics}
|
|
72
|
+
return e
|
|
73
|
+
|
|
74
|
+
return self._mutate(id, change, existing=existing)
|
|
75
|
+
|
|
76
|
+
def add_artifacts(
|
|
77
|
+
self,
|
|
78
|
+
id: str,
|
|
79
|
+
links: Iterable[str],
|
|
80
|
+
*,
|
|
81
|
+
existing: Experiment | None = None,
|
|
82
|
+
) -> Experiment:
|
|
83
|
+
def change(e: Experiment) -> Experiment:
|
|
84
|
+
existing_links = set(e.artifacts)
|
|
85
|
+
for link in links:
|
|
86
|
+
if link not in existing_links:
|
|
87
|
+
e.artifacts.append(link)
|
|
88
|
+
existing_links.add(link)
|
|
89
|
+
return e
|
|
90
|
+
|
|
91
|
+
return self._mutate(id, change, existing=existing)
|
|
92
|
+
|
|
93
|
+
def record_history(self, points: Iterable[MetricPoint]) -> None:
|
|
94
|
+
if self._metrics is None:
|
|
95
|
+
raise ExperimentValidationError(
|
|
96
|
+
"No metric repository wired — cannot record step history."
|
|
97
|
+
)
|
|
98
|
+
self._metrics.append(points)
|
|
99
|
+
|
|
100
|
+
def remove(self, id: str) -> None:
|
|
101
|
+
self._repo.delete(id)
|
|
102
|
+
|
|
103
|
+
def _mutate(self, id: str, change, *, existing: Experiment | None = None) -> Experiment:
|
|
104
|
+
base = existing if existing is not None else self._repo.get_by_id(id)
|
|
105
|
+
if base is None:
|
|
106
|
+
raise ExperimentValidationError(f'No experiment with id "{id}".')
|
|
107
|
+
updated = change(base)
|
|
108
|
+
self._repo.save(updated)
|
|
109
|
+
return updated
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""The ``Run`` — the one handle every capability hangs off.
|
|
2
|
+
|
|
3
|
+
A run wraps a single experiment row and buffers step-indexed metric history so
|
|
4
|
+
training loops can log cheaply. It's created and finalized by :func:`track` /
|
|
5
|
+
:func:`track_experiment`, which stamp status and flush on exit. New capabilities
|
|
6
|
+
(more loggers, more artifact kinds) become new ``Run`` methods or new registered
|
|
7
|
+
sync sources — never new call sites in user code. That's the design intent
|
|
8
|
+
behind "the decorator does everything down the line."
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
from collections.abc import Callable, Iterable
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ....sync.registry import SyncRegistry, default_registry
|
|
19
|
+
from ....sync.source import ArtifactSource, MetricSource, Mirror
|
|
20
|
+
from ...experiments.domain.experiment import Experiment, ExperimentStatus
|
|
21
|
+
from ...experiments.domain.metric_point import MetricPoint
|
|
22
|
+
from .manage_experiment import ManageExperimentUseCase
|
|
23
|
+
|
|
24
|
+
#: Uploader signature: (experiment_id, name, data, content_type) -> link URL.
|
|
25
|
+
Uploader = Callable[[str, str, bytes, str], str]
|
|
26
|
+
|
|
27
|
+
_FLUSH_EVERY = 1000
|
|
28
|
+
_CONTENT_TYPES = {
|
|
29
|
+
"png": "image/png",
|
|
30
|
+
"webp": "image/webp",
|
|
31
|
+
"svg": "image/svg+xml",
|
|
32
|
+
"pdf": "application/pdf",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Run:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
manage: ManageExperimentUseCase,
|
|
40
|
+
experiment: Experiment,
|
|
41
|
+
*,
|
|
42
|
+
uploader: Uploader | None = None,
|
|
43
|
+
registry: SyncRegistry = default_registry,
|
|
44
|
+
mirror: Mirror | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._manage = manage
|
|
47
|
+
self.experiment = experiment
|
|
48
|
+
self._uploader = uploader
|
|
49
|
+
self._registry = registry
|
|
50
|
+
self._mirror = mirror
|
|
51
|
+
self._buffer: list[MetricPoint] = []
|
|
52
|
+
self._last: dict[str, float] = {}
|
|
53
|
+
self._fig_seq = 0
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def id(self) -> str:
|
|
57
|
+
return self.experiment.id
|
|
58
|
+
|
|
59
|
+
# --- metrics ---------------------------------------------------------
|
|
60
|
+
def log_metric(self, name: str, value: float, step: int | None = None) -> None:
|
|
61
|
+
"""One metric sample. With ``step`` it joins the curve (history); without
|
|
62
|
+
a step it's a summary value written to ``experiments.metrics``."""
|
|
63
|
+
if step is None:
|
|
64
|
+
self.log_summary({name: value})
|
|
65
|
+
return
|
|
66
|
+
wall = datetime.now(timezone.utc).isoformat()
|
|
67
|
+
self._buffer.append(MetricPoint(self.id, name, int(step), float(value), wall))
|
|
68
|
+
self._last[name] = float(value)
|
|
69
|
+
if self._mirror is not None:
|
|
70
|
+
self._mirror.log({name: float(value)}, int(step))
|
|
71
|
+
if len(self._buffer) >= _FLUSH_EVERY:
|
|
72
|
+
self.flush()
|
|
73
|
+
|
|
74
|
+
def log_metrics(self, metrics: dict[str, float], step: int | None = None) -> None:
|
|
75
|
+
if step is None:
|
|
76
|
+
self.log_summary(metrics)
|
|
77
|
+
return
|
|
78
|
+
for name, value in metrics.items():
|
|
79
|
+
self.log_metric(name, value, step)
|
|
80
|
+
|
|
81
|
+
def log_summary(self, metrics: dict[str, Any]) -> None:
|
|
82
|
+
"""Merge flat summary values into ``experiments.metrics`` (the chips the
|
|
83
|
+
dashboard shows)."""
|
|
84
|
+
self.experiment = self._manage.record_metrics(
|
|
85
|
+
self.id, dict(metrics), existing=self.experiment
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# --- artifacts -------------------------------------------------------
|
|
89
|
+
def log_figure(self, figure: Any, name: str | None = None, fmt: str = "png") -> str:
|
|
90
|
+
"""Save a matplotlib (or any ``savefig``-capable) figure and attach it.
|
|
91
|
+
|
|
92
|
+
Duck-typed on ``savefig`` so this needs no matplotlib import; the
|
|
93
|
+
``[figures]`` extra only adds WebP compression on top (see sync.matplotlib).
|
|
94
|
+
"""
|
|
95
|
+
buf = io.BytesIO()
|
|
96
|
+
figure.savefig(buf, format=fmt, bbox_inches="tight")
|
|
97
|
+
self._fig_seq += 1
|
|
98
|
+
fname = name or f"figure-{self._fig_seq}"
|
|
99
|
+
if "." not in fname:
|
|
100
|
+
fname = f"{fname}.{fmt}"
|
|
101
|
+
ctype = _CONTENT_TYPES.get(fmt, "application/octet-stream")
|
|
102
|
+
return self.log_bytes(fname, buf.getvalue(), ctype)
|
|
103
|
+
|
|
104
|
+
def log_bytes(
|
|
105
|
+
self, name: str, data: bytes, content_type: str = "application/octet-stream"
|
|
106
|
+
) -> str:
|
|
107
|
+
if self._uploader is None:
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
"This run has no artifact storage wired — cannot upload bytes. "
|
|
110
|
+
"Use a run created via track()/track_experiment(), or pass log_artifact(url)."
|
|
111
|
+
)
|
|
112
|
+
url = self._uploader(self.id, name, data, content_type)
|
|
113
|
+
self.experiment = self._manage.add_artifacts(self.id, [url], existing=self.experiment)
|
|
114
|
+
return url
|
|
115
|
+
|
|
116
|
+
def log_artifact(self, url: str) -> None:
|
|
117
|
+
"""Attach an already-hosted link (a wandb run, an S3 object, …)."""
|
|
118
|
+
self.experiment = self._manage.add_artifacts(self.id, [url], existing=self.experiment)
|
|
119
|
+
|
|
120
|
+
# --- status ----------------------------------------------------------
|
|
121
|
+
def set_status(self, status: ExperimentStatus) -> None:
|
|
122
|
+
self.experiment = self._manage.set_status(self.id, status, existing=self.experiment)
|
|
123
|
+
# A run has one ending, and this is where every path reaches it: the
|
|
124
|
+
# mirror is told once and then forgotten, so a later status change
|
|
125
|
+
# cannot reopen a run somebody else has already closed.
|
|
126
|
+
if self._mirror is not None:
|
|
127
|
+
self._mirror.finish(status)
|
|
128
|
+
self._mirror = None
|
|
129
|
+
|
|
130
|
+
# --- sync sources ----------------------------------------------------
|
|
131
|
+
def sync(self, source_id: str, ref: Any) -> None:
|
|
132
|
+
"""Pull curves and/or artifacts from a registered source.
|
|
133
|
+
|
|
134
|
+
Works with any source in the registry, so a user's own ``MetricSource``
|
|
135
|
+
is driven by the exact same call as the built-in ones (Open/Closed)."""
|
|
136
|
+
source = self._registry.get(source_id)
|
|
137
|
+
# A source may be a MetricSource, an ArtifactSource, or both; the
|
|
138
|
+
# runtime-checkable protocols let us dispatch on the roles it fills.
|
|
139
|
+
if isinstance(source, MetricSource):
|
|
140
|
+
self._ingest_series(source.read(ref))
|
|
141
|
+
if isinstance(source, ArtifactSource):
|
|
142
|
+
self._ingest_artifacts(source.collect(ref))
|
|
143
|
+
|
|
144
|
+
def sync_tensorboard(self, logdir: str) -> None:
|
|
145
|
+
self.sync("tensorboard", logdir)
|
|
146
|
+
|
|
147
|
+
def sync_wandb(self, run_path: str) -> None:
|
|
148
|
+
self.sync("wandb", run_path)
|
|
149
|
+
|
|
150
|
+
def _ingest_series(self, series: Iterable[Any]) -> None:
|
|
151
|
+
for s in series:
|
|
152
|
+
self._buffer.extend(s.to_points(self.id))
|
|
153
|
+
if s.last_value is not None:
|
|
154
|
+
self._last[s.metric] = s.last_value
|
|
155
|
+
if len(self._buffer) >= _FLUSH_EVERY:
|
|
156
|
+
self.flush()
|
|
157
|
+
|
|
158
|
+
def _ingest_artifacts(self, artifacts: Iterable[Any]) -> None:
|
|
159
|
+
for art in artifacts:
|
|
160
|
+
if art.url:
|
|
161
|
+
self.log_artifact(art.url)
|
|
162
|
+
elif art.data is not None:
|
|
163
|
+
self.log_bytes(art.name, art.data, art.content_type)
|
|
164
|
+
|
|
165
|
+
# --- lifecycle -------------------------------------------------------
|
|
166
|
+
def flush(self) -> None:
|
|
167
|
+
"""Persist buffered history and fold the latest per-metric value into the
|
|
168
|
+
summary so the dashboard shows final numbers even without the chart."""
|
|
169
|
+
if self._buffer:
|
|
170
|
+
self._manage.record_history(self._buffer)
|
|
171
|
+
self._buffer.clear()
|
|
172
|
+
if self._last:
|
|
173
|
+
self.log_summary(dict(self._last))
|
|
174
|
+
self._last.clear()
|