ruthless-efficiency 0.2.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.
- ruthless/__init__.py +74 -0
- ruthless/_io.py +37 -0
- ruthless/_logging.py +10 -0
- ruthless/backend.py +34 -0
- ruthless/backends/__init__.py +67 -0
- ruthless/backends/base.py +55 -0
- ruthless/backends/docker.py +20 -0
- ruthless/backends/hf_jobs.py +215 -0
- ruthless/backends/local_cuda.py +74 -0
- ruthless/backends/pool.py +91 -0
- ruthless/backends/remote_ssh.py +258 -0
- ruthless/backends/remote_worker.py +107 -0
- ruthless/cli.py +58 -0
- ruthless/config/__init__.py +55 -0
- ruthless/config/common.py +120 -0
- ruthless/config/space.py +51 -0
- ruthless/config/strategies.py +81 -0
- ruthless/errors.py +33 -0
- ruthless/guards.py +12 -0
- ruthless/objective.py +38 -0
- ruthless/parallel.py +42 -0
- ruthless/remote.py +64 -0
- ruthless/report.py +49 -0
- ruthless/result.py +52 -0
- ruthless/strategies/__init__.py +0 -0
- ruthless/strategies/evolve_/__init__.py +8 -0
- ruthless/strategies/evolve_/evaluator.py +163 -0
- ruthless/strategies/evolve_/sandbox.py +604 -0
- ruthless/strategies/evolve_/strategy.py +391 -0
- ruthless/strategies/optuna_/__init__.py +8 -0
- ruthless/strategies/optuna_/strategy.py +124 -0
- ruthless/strategies/random_/__init__.py +7 -0
- ruthless/strategies/random_/strategy.py +71 -0
- ruthless/strategy.py +22 -0
- ruthless/testing.py +44 -0
- ruthless/wire.py +39 -0
- ruthless_efficiency-0.2.0.dist-info/METADATA +125 -0
- ruthless_efficiency-0.2.0.dist-info/RECORD +42 -0
- ruthless_efficiency-0.2.0.dist-info/WHEEL +4 -0
- ruthless_efficiency-0.2.0.dist-info/entry_points.txt +2 -0
- ruthless_efficiency-0.2.0.dist-info/licenses/LICENSE +21 -0
- ruthless_efficiency-0.2.0.dist-info/licenses/NOTICE +93 -0
ruthless/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Ruthless Efficiency — general optimisation/search substrate.
|
|
2
|
+
|
|
3
|
+
This module is the curated public API: import the supported surface from `ruthless` directly
|
|
4
|
+
(`from ruthless import Candidate, RandomConfig, RandomSearchStrategy, InProcessBackend`). Deep
|
|
5
|
+
submodule paths (e.g. `ruthless.strategies.random_.strategy`) are implementation detail and may move
|
|
6
|
+
before `1.0`. The optuna/evolve strategies and the compute backends live behind their extras and are
|
|
7
|
+
imported from their own namespaces (`ruthless.strategies.optuna_`, `ruthless.strategies.evolve_`,
|
|
8
|
+
`ruthless.backends`) so that `import ruthless` stays dependency-light (core only)."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ruthless.backend import ComputeBackend, InProcessBackend
|
|
13
|
+
from ruthless.config import (
|
|
14
|
+
Choice,
|
|
15
|
+
EvolveConfig,
|
|
16
|
+
FloatRange,
|
|
17
|
+
IntRange,
|
|
18
|
+
OptunaConfig,
|
|
19
|
+
RandomConfig,
|
|
20
|
+
RuthlessConfig,
|
|
21
|
+
)
|
|
22
|
+
from ruthless.errors import (
|
|
23
|
+
FatalEvaluationError,
|
|
24
|
+
OptimizationError,
|
|
25
|
+
TransientEvaluationError,
|
|
26
|
+
classify_metric,
|
|
27
|
+
)
|
|
28
|
+
from ruthless.guards import penalty_metrics
|
|
29
|
+
from ruthless.objective import CachedObjective, Objective
|
|
30
|
+
from ruthless.remote import RemoteObjective, RemoteRef
|
|
31
|
+
from ruthless.report import render_json, render_summary_md
|
|
32
|
+
from ruthless.result import Candidate, Evaluation, Metrics, Result
|
|
33
|
+
from ruthless.strategies.random_.strategy import RandomSearchStrategy
|
|
34
|
+
from ruthless.strategy import Direction, SearchStrategy
|
|
35
|
+
from ruthless.testing import assert_cache_equivalence
|
|
36
|
+
|
|
37
|
+
__version__ = "0.2.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"CachedObjective",
|
|
41
|
+
# value types
|
|
42
|
+
"Candidate",
|
|
43
|
+
"Choice",
|
|
44
|
+
"ComputeBackend",
|
|
45
|
+
"Direction",
|
|
46
|
+
"Evaluation",
|
|
47
|
+
"EvolveConfig",
|
|
48
|
+
"FatalEvaluationError",
|
|
49
|
+
"FloatRange",
|
|
50
|
+
"InProcessBackend",
|
|
51
|
+
"IntRange",
|
|
52
|
+
"Metrics",
|
|
53
|
+
# ports
|
|
54
|
+
"Objective",
|
|
55
|
+
# errors + guards
|
|
56
|
+
"OptimizationError",
|
|
57
|
+
"OptunaConfig",
|
|
58
|
+
"RandomConfig",
|
|
59
|
+
# built-in strategy
|
|
60
|
+
"RandomSearchStrategy",
|
|
61
|
+
"RemoteObjective",
|
|
62
|
+
"RemoteRef",
|
|
63
|
+
"Result",
|
|
64
|
+
# config surface
|
|
65
|
+
"RuthlessConfig",
|
|
66
|
+
"SearchStrategy",
|
|
67
|
+
"TransientEvaluationError",
|
|
68
|
+
"assert_cache_equivalence",
|
|
69
|
+
"classify_metric",
|
|
70
|
+
"penalty_metrics",
|
|
71
|
+
# reporting + cache-equivalence harness
|
|
72
|
+
"render_json",
|
|
73
|
+
"render_summary_md",
|
|
74
|
+
]
|
ruthless/_io.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Tiny filesystem helper shared across the core, strategies, and backends.
|
|
2
|
+
|
|
3
|
+
`program_to_path` gives every caller a UNIFORM `program_path` contract — a filesystem path to a
|
|
4
|
+
`.py` file holding `Candidate.program`, or `None` when there is none — so no caller ever passes raw
|
|
5
|
+
source around. It lives in the pure core (stdlib-only) so both `ruthless.backends` and
|
|
6
|
+
`ruthless.strategies` can reuse it without crossing the one-way dependency boundary (strategies must
|
|
7
|
+
not import backends)."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
|
|
16
|
+
from ruthless.result import Candidate
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contextmanager
|
|
20
|
+
def program_to_path(candidate: Candidate) -> Iterator[str | None]:
|
|
21
|
+
"""Yield a temp `.py` path holding `candidate.program`, or `None` if there is none.
|
|
22
|
+
|
|
23
|
+
The temp file is removed on exit (best-effort). Callers get a path-or-None they can hand to a
|
|
24
|
+
`train_and_evaluate(..., program_path=...)` entrypoint without branching on raw source."""
|
|
25
|
+
if candidate.program is None:
|
|
26
|
+
yield None
|
|
27
|
+
return
|
|
28
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as tmp:
|
|
29
|
+
tmp.write(candidate.program)
|
|
30
|
+
path = tmp.name
|
|
31
|
+
try:
|
|
32
|
+
yield path
|
|
33
|
+
finally:
|
|
34
|
+
try:
|
|
35
|
+
os.unlink(path)
|
|
36
|
+
except OSError:
|
|
37
|
+
pass
|
ruthless/_logging.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Observability: namespaced loggers under "ruthless.*". The library NEVER configures root logging
|
|
2
|
+
or adds handlers — consumers attach their own (spec M3)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_logger(name: str) -> logging.Logger:
|
|
10
|
+
return logging.getLogger(f"ruthless.{name}")
|
ruthless/backend.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""ComputeBackend port + the in-process backend. A backend is the INTER-candidate dispatch path
|
|
2
|
+
(one evaluation → one compute resource). It returns the objective's metrics verbatim — it does NOT
|
|
3
|
+
inspect metric values; the strategy validates its own SCORED metric (review C-C). `timeout` is part
|
|
4
|
+
of the port from day one (review H-C); InProcessBackend documents-and-ignores it (only the remote
|
|
5
|
+
backends in Plan 1B enforce per-candidate timeout + the transient-retry contract)."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from ruthless.objective import Objective
|
|
12
|
+
from ruthless.result import Candidate, Metrics
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class ComputeBackend(Protocol):
|
|
17
|
+
def evaluate(self, candidate: Candidate, objective: Objective, *, timeout: float | None = None) -> Metrics: ...
|
|
18
|
+
def available(self) -> bool: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InProcessBackend:
|
|
22
|
+
"""The default core backend: calls ``objective.evaluate(candidate)`` in the current process.
|
|
23
|
+
|
|
24
|
+
For pure/CPU objectives that need no remote dispatch. ``timeout`` is accepted for port
|
|
25
|
+
compatibility and ignored (there is no in-process cancellation); the remote backends in the
|
|
26
|
+
``[backends]`` extra enforce it.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def evaluate(self, candidate: Candidate, objective: Objective, *, timeout: float | None = None) -> Metrics:
|
|
30
|
+
# timeout ignored in-process (no cross-process cancellation here); enforced by remote backends in 1B.
|
|
31
|
+
return objective.evaluate(candidate)
|
|
32
|
+
|
|
33
|
+
def available(self) -> bool:
|
|
34
|
+
return True
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Compute backends ([backends] extra) + the create_backend factory.
|
|
2
|
+
|
|
3
|
+
A single backend name builds one backend; a comma-separated `type` builds one per name wrapped in a
|
|
4
|
+
priority-ordered BackendPool. Concrete-backend imports are DEFERRED inside the factories so the heavy
|
|
5
|
+
per-backend deps (huggingface_hub, docker, torch) load only when that backend is actually used."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from ruthless.backend import ComputeBackend
|
|
12
|
+
from ruthless.config import BackendConfig
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
__all__ = ["create_backend"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _make_local_cuda(cfg: BackendConfig, timeout: int) -> ComputeBackend:
|
|
21
|
+
from ruthless.backends.local_cuda import LocalCudaBackend
|
|
22
|
+
|
|
23
|
+
return LocalCudaBackend(device=cfg.device)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _make_docker(cfg: BackendConfig, timeout: int) -> ComputeBackend:
|
|
27
|
+
from ruthless.backends.docker import DockerBackend
|
|
28
|
+
|
|
29
|
+
return DockerBackend(docker_image=cfg.docker_image or "")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _make_hf_jobs(cfg: BackendConfig, timeout: int) -> ComputeBackend:
|
|
33
|
+
from ruthless.backends.hf_jobs import HFJobsBackend
|
|
34
|
+
|
|
35
|
+
return HFJobsBackend(hf_flavor=cfg.hf_flavor or "l40sx1", timeout=timeout)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _make_remote_ssh(cfg: BackendConfig, timeout: int) -> ComputeBackend:
|
|
39
|
+
from ruthless.backends.remote_ssh import RemoteSSHBackend
|
|
40
|
+
|
|
41
|
+
return RemoteSSHBackend(
|
|
42
|
+
host=cfg.ssh_host or "",
|
|
43
|
+
user=cfg.ssh_user or "",
|
|
44
|
+
remote_dir=cfg.ssh_remote_dir or "",
|
|
45
|
+
python_path=cfg.ssh_python_path or "",
|
|
46
|
+
timeout=timeout,
|
|
47
|
+
device=cfg.device,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_REGISTRY = {
|
|
52
|
+
"local_cuda": _make_local_cuda,
|
|
53
|
+
"docker": _make_docker,
|
|
54
|
+
"hf_jobs": _make_hf_jobs,
|
|
55
|
+
"remote_ssh": _make_remote_ssh,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_backend(config: BackendConfig, *, timeout: int = 900) -> ComputeBackend:
|
|
60
|
+
"""Build a ComputeBackend (or BackendPool) from a validated BackendConfig."""
|
|
61
|
+
types = [t.strip() for t in config.type.split(",") if t.strip()]
|
|
62
|
+
backends = [_REGISTRY[t](config, timeout) for t in types]
|
|
63
|
+
if len(backends) == 1:
|
|
64
|
+
return backends[0]
|
|
65
|
+
from ruthless.backends.pool import BackendPool
|
|
66
|
+
|
|
67
|
+
return BackendPool(backends)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Helpers shared by the compute backends ([backends] extra). The ComputeBackend port lives in
|
|
2
|
+
ruthless.backend (1A, core); backends import it from there. These helpers replace the lakehouse
|
|
3
|
+
fail_metrics() swallowing with the 1A error taxonomy (raise Fatal/Transient, never record a sentinel).
|
|
4
|
+
|
|
5
|
+
`program_to_path` and the cross-wire failure-marker vocabulary live in the core (`ruthless._io`,
|
|
6
|
+
`ruthless.wire`) so strategies can reuse them without importing backends; they are re-exported here
|
|
7
|
+
for the backends that already import them from this module."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from ruthless._io import program_to_path
|
|
14
|
+
from ruthless.errors import FatalEvaluationError
|
|
15
|
+
from ruthless.remote import RemoteObjective
|
|
16
|
+
from ruthless.wire import is_failure_marker
|
|
17
|
+
|
|
18
|
+
__all__ = ["is_objective_failure", "parse_last_json_line", "program_to_path", "require_remote"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_objective_failure(metrics: dict) -> bool:
|
|
22
|
+
"""True if a parsed worker metrics dict is actually a node-side objective-failure marker (H1).
|
|
23
|
+
|
|
24
|
+
Thin alias over `ruthless.wire.is_failure_marker` (the single definition of the marker shape)."""
|
|
25
|
+
return is_failure_marker(metrics)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_last_json_line(stdout: str) -> dict[str, float]:
|
|
29
|
+
"""Return the last non-empty line of `stdout` parsed as a JSON metrics dict.
|
|
30
|
+
|
|
31
|
+
Mirrors the lakehouse remote backends: the worker prints exactly one JSON line; earlier lines
|
|
32
|
+
may be stray warnings. Raises FatalEvaluationError if no JSON object line is present. Scans from
|
|
33
|
+
the end without materialising an intermediate filtered list."""
|
|
34
|
+
for raw_line in reversed(stdout.splitlines()):
|
|
35
|
+
line = raw_line.strip()
|
|
36
|
+
if line.startswith("{") and line.endswith("}"):
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(line)
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
continue
|
|
41
|
+
raise FatalEvaluationError(f"no JSON metrics line in worker stdout (last 200 chars): {stdout[-200:]!r}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def require_remote(objective: object, *, backend: str) -> RemoteObjective:
|
|
45
|
+
"""Return the objective narrowed to RemoteObjective, or raise FatalEvaluationError if it is not one.
|
|
46
|
+
|
|
47
|
+
Compute backends cannot ship an arbitrary in-process Objective to a node — the objective must opt
|
|
48
|
+
in to remote execution by exposing `remote_ref` (and `epochs`/`seed`). Returning the narrowed
|
|
49
|
+
objective lets callers read `.remote_ref`/`.epochs`/`.seed` without a separate isinstance assert."""
|
|
50
|
+
if not isinstance(objective, RemoteObjective):
|
|
51
|
+
raise FatalEvaluationError(
|
|
52
|
+
f"{backend} requires a RemoteObjective (with .remote_ref/.epochs/.seed); "
|
|
53
|
+
f"got {type(objective).__name__}. Use InProcessBackend for non-remote objectives."
|
|
54
|
+
)
|
|
55
|
+
return objective
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""DockerBackend — not-yet-implemented stub (ported from the lakehouse stub, bridged to the 1A port).
|
|
2
|
+
Under the unified error model a not-implemented backend is a fatal config error, never a silently
|
|
3
|
+
recorded sentinel score."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from ruthless.errors import FatalEvaluationError
|
|
8
|
+
from ruthless.objective import Objective
|
|
9
|
+
from ruthless.result import Candidate, Metrics
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DockerBackend:
|
|
13
|
+
def __init__(self, docker_image: str = "") -> None:
|
|
14
|
+
self._docker_image = docker_image
|
|
15
|
+
|
|
16
|
+
def evaluate(self, candidate: Candidate, objective: Objective, *, timeout: float | None = None) -> Metrics:
|
|
17
|
+
raise FatalEvaluationError("DockerBackend is not implemented (Plan 1B stub); use local_cuda/remote_ssh/hf_jobs")
|
|
18
|
+
|
|
19
|
+
def available(self) -> bool:
|
|
20
|
+
return False
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""HFJobsBackend — runs an objective's remote entrypoint on Hugging Face Jobs.
|
|
2
|
+
|
|
3
|
+
Ported from the lakehouse backend, bridged to the 1A port. Submits a PEP-723 UV worker script via
|
|
4
|
+
`huggingface_hub.run_uv_job`, polls until completion, and parses the JSON metrics line from the job
|
|
5
|
+
logs. The `shared.wheel` lakehouse coupling is SEVERED: the worker's install spec is the injected
|
|
6
|
+
`RemoteRef.package` and the import target is `RemoteRef.entrypoint` (no `evolve.targets.<target>`).
|
|
7
|
+
|
|
8
|
+
Per the unified error model it RAISES rather than recording a sentinel: timeout -> cancel +
|
|
9
|
+
TransientEvaluationError; job ERROR/CANCELED/DELETED -> FatalEvaluationError; no metrics line or a
|
|
10
|
+
node-side objective-failure marker -> FatalEvaluationError."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from ruthless._logging import get_logger
|
|
23
|
+
from ruthless.backends.base import is_objective_failure, parse_last_json_line, require_remote
|
|
24
|
+
from ruthless.errors import FatalEvaluationError, TransientEvaluationError
|
|
25
|
+
from ruthless.objective import Objective
|
|
26
|
+
from ruthless.remote import RemoteObjective, RemoteRef
|
|
27
|
+
from ruthless.result import Candidate, Metrics
|
|
28
|
+
from ruthless.wire import ERROR_TEXT_KEY, ERROR_TEXT_SURFACE_LIMIT
|
|
29
|
+
|
|
30
|
+
_log = get_logger("backends.hf_jobs")
|
|
31
|
+
_POLL_INTERVAL = 15 # seconds
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_worker_script(ref: RemoteRef) -> str:
|
|
35
|
+
"""Build the PEP-723 UV worker script, interpolating the injected install spec + entrypoint.
|
|
36
|
+
|
|
37
|
+
Replaces the lakehouse module-level f-string that baked in `shared.wheel.WHEEL_BASE_URL`. The
|
|
38
|
+
failure-marker keys emitted below (`combined_score`/`error`/`_error_text`) are the cross-wire
|
|
39
|
+
contract defined in `ruthless.wire`; they are written as literals here because this script runs as
|
|
40
|
+
a self-contained worker on a fresh node — keep them in sync with `ruthless.wire`."""
|
|
41
|
+
if ref.package is None:
|
|
42
|
+
raise FatalEvaluationError(
|
|
43
|
+
"HFJobsBackend requires RemoteRef.package (the node is fresh; it must install something)"
|
|
44
|
+
)
|
|
45
|
+
return f'''\
|
|
46
|
+
# /// script
|
|
47
|
+
# requires-python = ">=3.10"
|
|
48
|
+
# dependencies = [
|
|
49
|
+
# "{ref.package}",
|
|
50
|
+
# ]
|
|
51
|
+
# ///
|
|
52
|
+
"""HF Jobs worker — generic ruthless remote entrypoint runner."""
|
|
53
|
+
|
|
54
|
+
import base64
|
|
55
|
+
import importlib
|
|
56
|
+
import json
|
|
57
|
+
import logging
|
|
58
|
+
import os
|
|
59
|
+
import sys
|
|
60
|
+
|
|
61
|
+
logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="%(name)s %(message)s")
|
|
62
|
+
_log = logging.getLogger("hf_jobs_worker")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main() -> None:
|
|
66
|
+
config_b64 = os.environ.get("EVOLVE_CANDIDATE_CONFIG", "")
|
|
67
|
+
if not config_b64:
|
|
68
|
+
print(json.dumps({{"combined_score": 0.0, "error": 1.0, "_error_text": "missing EVOLVE_CANDIDATE_CONFIG"}}))
|
|
69
|
+
sys.exit(0)
|
|
70
|
+
|
|
71
|
+
candidate_config = json.loads(base64.b64decode(config_b64).decode())
|
|
72
|
+
device = os.environ.get("EVOLVE_DEVICE", "cuda:0")
|
|
73
|
+
epochs = int(os.environ.get("EVOLVE_EPOCHS", "5"))
|
|
74
|
+
seed = int(os.environ.get("EVOLVE_SEED", "42"))
|
|
75
|
+
entrypoint = os.environ["EVOLVE_ENTRYPOINT"]
|
|
76
|
+
|
|
77
|
+
program_path = None
|
|
78
|
+
program_b64 = os.environ.get("EVOLVE_PROGRAM")
|
|
79
|
+
if program_b64:
|
|
80
|
+
program_path = "/tmp/ruthless_program.py"
|
|
81
|
+
with open(program_path, "w") as fh:
|
|
82
|
+
fh.write(base64.b64decode(program_b64).decode())
|
|
83
|
+
|
|
84
|
+
module_path, _, attr = entrypoint.partition(":")
|
|
85
|
+
fn = getattr(importlib.import_module(module_path), attr)
|
|
86
|
+
try:
|
|
87
|
+
metrics = fn(
|
|
88
|
+
candidate_config=candidate_config, device=device, epochs=epochs, seed=seed, program_path=program_path
|
|
89
|
+
)
|
|
90
|
+
except Exception:
|
|
91
|
+
import traceback
|
|
92
|
+
metrics = {{"combined_score": 0.0, "error": 1.0, "_error_text": traceback.format_exc()}}
|
|
93
|
+
|
|
94
|
+
print(json.dumps(metrics))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|
|
99
|
+
'''
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class HFJobsBackend:
|
|
103
|
+
"""Runs an objective's remote entrypoint on Hugging Face Jobs (the ``[backends]`` extra).
|
|
104
|
+
|
|
105
|
+
Submits a PEP-723 UV worker script via ``huggingface_hub.run_uv_job``, polls to completion, and
|
|
106
|
+
parses the JSON metrics line from the job logs. The worker installs ``RemoteRef.package`` and
|
|
107
|
+
imports ``RemoteRef.entrypoint``. Enforces ``timeout`` (cancel + ``TransientEvaluationError``);
|
|
108
|
+
a job ERROR/CANCELED/DELETED or a node-side objective-failure marker → ``FatalEvaluationError``.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
hf_flavor: HF Jobs hardware flavor (e.g. ``"l40sx1"``).
|
|
112
|
+
timeout: Per-candidate timeout in seconds (also the job submission timeout).
|
|
113
|
+
namespace: HF namespace to run the job under (defaults to the token owner).
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, hf_flavor: str = "l40sx1", timeout: int = 6000, namespace: str | None = None) -> None:
|
|
117
|
+
self._hf_flavor = hf_flavor
|
|
118
|
+
self._timeout = timeout
|
|
119
|
+
self._namespace = namespace
|
|
120
|
+
# The worker script depends only on ref.package, which is constant across a run's candidates;
|
|
121
|
+
# cache it so we don't re-template the multi-KB f-string on every evaluation.
|
|
122
|
+
self._worker_script_cache: dict[str | None, str] = {}
|
|
123
|
+
|
|
124
|
+
def _worker_script(self, ref: RemoteRef) -> str:
|
|
125
|
+
if ref.package not in self._worker_script_cache:
|
|
126
|
+
self._worker_script_cache[ref.package] = _build_worker_script(ref)
|
|
127
|
+
return self._worker_script_cache[ref.package]
|
|
128
|
+
|
|
129
|
+
def evaluate(self, candidate: Candidate, objective: Objective, *, timeout: float | None = None) -> Metrics:
|
|
130
|
+
obj = require_remote(objective, backend="HFJobsBackend")
|
|
131
|
+
# objective-crash markers and job failures raise (below); transport/submission errors -> Transient.
|
|
132
|
+
try:
|
|
133
|
+
return self._evaluate_impl(candidate, obj, obj.remote_ref)
|
|
134
|
+
except (FatalEvaluationError, TransientEvaluationError):
|
|
135
|
+
raise
|
|
136
|
+
except Exception as exc: # submission / API / network -> transient
|
|
137
|
+
raise TransientEvaluationError(f"HF Jobs submission/poll error: {exc}") from exc
|
|
138
|
+
|
|
139
|
+
def _evaluate_impl(self, candidate: Candidate, objective: RemoteObjective, ref: RemoteRef) -> Metrics:
|
|
140
|
+
from huggingface_hub import HfApi
|
|
141
|
+
|
|
142
|
+
api = HfApi()
|
|
143
|
+
script = self._worker_script(ref)
|
|
144
|
+
|
|
145
|
+
env: dict[str, str] = {
|
|
146
|
+
"EVOLVE_CANDIDATE_CONFIG": base64.b64encode(json.dumps(dict(candidate.params)).encode()).decode(),
|
|
147
|
+
"EVOLVE_DEVICE": "cuda:0",
|
|
148
|
+
"EVOLVE_EPOCHS": str(objective.epochs),
|
|
149
|
+
"EVOLVE_SEED": str(objective.seed),
|
|
150
|
+
"EVOLVE_ENTRYPOINT": ref.entrypoint,
|
|
151
|
+
}
|
|
152
|
+
if candidate.program is not None:
|
|
153
|
+
env["EVOLVE_PROGRAM"] = base64.b64encode(candidate.program.encode()).decode()
|
|
154
|
+
|
|
155
|
+
# The worker script only needs to exist on disk during submission (run_uv_job reads + uploads
|
|
156
|
+
# it); the temp dir is cleaned up before polling so it does not accumulate one dir per trial.
|
|
157
|
+
with tempfile.TemporaryDirectory(prefix="ruthless_hfjob_") as tmp_dir:
|
|
158
|
+
script_file = Path(tmp_dir) / "worker.py"
|
|
159
|
+
script_file.write_text(script, encoding="utf-8")
|
|
160
|
+
job_info = api.run_uv_job(
|
|
161
|
+
script=str(script_file),
|
|
162
|
+
env=env,
|
|
163
|
+
secrets={"HF_TOKEN": self._get_hf_token()},
|
|
164
|
+
flavor=self._hf_flavor,
|
|
165
|
+
timeout=f"{self._timeout}s",
|
|
166
|
+
namespace=self._namespace,
|
|
167
|
+
)
|
|
168
|
+
_log.info("hf_job_submitted", extra={"job_id": job_info.id, "flavor": self._hf_flavor})
|
|
169
|
+
return self._poll_job(api, job_info.id)
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _get_hf_token() -> str:
|
|
173
|
+
from huggingface_hub import get_token
|
|
174
|
+
|
|
175
|
+
return get_token() or ""
|
|
176
|
+
|
|
177
|
+
def _poll_job(self, api: Any, job_id: str) -> Metrics:
|
|
178
|
+
from huggingface_hub._jobs_api import JobStage
|
|
179
|
+
|
|
180
|
+
deadline = time.monotonic() + self._timeout
|
|
181
|
+
while time.monotonic() < deadline:
|
|
182
|
+
info = api.inspect_job(job_id=job_id, namespace=self._namespace)
|
|
183
|
+
stage = info.status.stage
|
|
184
|
+
if stage == JobStage.COMPLETED:
|
|
185
|
+
return self._parse_metrics(api, job_id, candidate_id=job_id)
|
|
186
|
+
if stage in (JobStage.ERROR, JobStage.CANCELED, JobStage.DELETED):
|
|
187
|
+
msg = info.status.message or "unknown"
|
|
188
|
+
raise FatalEvaluationError(f"HF Job {job_id} failed: stage={stage.value}, message={msg}")
|
|
189
|
+
time.sleep(_POLL_INTERVAL)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
api.cancel_job(job_id=job_id, namespace=self._namespace)
|
|
193
|
+
except Exception: # noqa: BLE001 - best-effort cancel; the timeout is reported regardless
|
|
194
|
+
_log.warning("cancel_failed", extra={"job_id": job_id})
|
|
195
|
+
raise TransientEvaluationError(f"HF Job {job_id} timed out after {self._timeout}s")
|
|
196
|
+
|
|
197
|
+
def _parse_metrics(self, api: Any, job_id: str, *, candidate_id: str) -> Metrics:
|
|
198
|
+
logs = "\n".join(api.fetch_job_logs(job_id=job_id, namespace=self._namespace))
|
|
199
|
+
metrics = parse_last_json_line(logs)
|
|
200
|
+
if is_objective_failure(metrics):
|
|
201
|
+
detail = str(metrics.get(ERROR_TEXT_KEY, ""))[:ERROR_TEXT_SURFACE_LIMIT]
|
|
202
|
+
raise FatalEvaluationError(f"objective crashed in HF Job {job_id}: {detail}")
|
|
203
|
+
return metrics
|
|
204
|
+
|
|
205
|
+
def available(self) -> bool:
|
|
206
|
+
if not os.environ.get("HF_TOKEN"):
|
|
207
|
+
return False
|
|
208
|
+
try:
|
|
209
|
+
from huggingface_hub import HfApi
|
|
210
|
+
|
|
211
|
+
HfApi().whoami()
|
|
212
|
+
return True
|
|
213
|
+
except Exception: # noqa: BLE001 - availability probe: any failure means "not available"
|
|
214
|
+
_log.warning("hf_unavailable")
|
|
215
|
+
return False
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""LocalCudaBackend — runs the objective's remote entrypoint IN-PROCESS on a local CUDA device.
|
|
2
|
+
Bridged to the 1A port: resolves objective.remote_ref.entrypoint (module:callable) and calls the
|
|
3
|
+
standard train_and_evaluate(candidate_config, device, epochs, seed, program_path)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import importlib
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
from ruthless.backends.base import program_to_path, require_remote
|
|
11
|
+
from ruthless.errors import FatalEvaluationError
|
|
12
|
+
from ruthless.objective import Objective
|
|
13
|
+
from ruthless.result import Candidate, Metrics
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _resolve(entrypoint: str):
|
|
17
|
+
module_path, _, attr = entrypoint.partition(":")
|
|
18
|
+
if not attr:
|
|
19
|
+
raise FatalEvaluationError(f"remote_ref.entrypoint must be 'module:callable', got {entrypoint!r}")
|
|
20
|
+
return getattr(importlib.import_module(module_path), attr)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LocalCudaBackend:
|
|
24
|
+
"""Runs an objective's remote entrypoint IN-PROCESS on a local CUDA device.
|
|
25
|
+
|
|
26
|
+
Resolves ``objective.remote_ref.entrypoint`` (``module:callable``) and invokes the standard
|
|
27
|
+
``train_and_evaluate(candidate_config, device, epochs, seed, program_path)`` at ``device``.
|
|
28
|
+
|
|
29
|
+
Timeout: like :class:`~ruthless.backend.InProcessBackend`, this backend runs in-process and does
|
|
30
|
+
NOT enforce the ``timeout`` argument — there is no in-process cancellation, so it is accepted for
|
|
31
|
+
port compatibility and ignored. Only the cross-process backends (:class:`RemoteSSHBackend`,
|
|
32
|
+
:class:`HFJobsBackend`) enforce a per-candidate timeout.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
device: CUDA device string the entrypoint runs on (e.g. ``"cuda:0"``).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, device: str = "cuda:0") -> None:
|
|
39
|
+
self._device = device
|
|
40
|
+
self._available_cached: bool | None = None
|
|
41
|
+
self._available_lock = threading.Lock() # guards the lazy probe (safe on free-threaded builds)
|
|
42
|
+
|
|
43
|
+
def evaluate(self, candidate: Candidate, objective: Objective, *, timeout: float | None = None) -> Metrics:
|
|
44
|
+
# timeout is ignored in-process (no cancellation here); enforced only by the remote backends.
|
|
45
|
+
obj = require_remote(objective, backend="LocalCudaBackend")
|
|
46
|
+
fn = _resolve(obj.remote_ref.entrypoint)
|
|
47
|
+
with program_to_path(candidate) as program_path: # uniform: temp .py path or None
|
|
48
|
+
try:
|
|
49
|
+
return fn(
|
|
50
|
+
candidate_config=dict(candidate.params), # consumers get a plain, mutable dict
|
|
51
|
+
device=self._device,
|
|
52
|
+
epochs=obj.epochs,
|
|
53
|
+
seed=obj.seed,
|
|
54
|
+
program_path=program_path,
|
|
55
|
+
)
|
|
56
|
+
except Exception as exc: # H1: any objective crash -> Fatal (surfaced, never a recorded score)
|
|
57
|
+
raise FatalEvaluationError(
|
|
58
|
+
f"objective entrypoint {obj.remote_ref.entrypoint!r} crashed on {candidate.id}: {exc}"
|
|
59
|
+
) from exc
|
|
60
|
+
|
|
61
|
+
def available(self) -> bool:
|
|
62
|
+
# Double-checked lazy probe: keep the heavy `torch` import deferred (don't import it at
|
|
63
|
+
# construction just to memoise), but guard the one-time init so concurrent callers on a
|
|
64
|
+
# free-threaded (no-GIL) build can't race on `_available_cached`.
|
|
65
|
+
if self._available_cached is None:
|
|
66
|
+
with self._available_lock:
|
|
67
|
+
if self._available_cached is None:
|
|
68
|
+
try:
|
|
69
|
+
import torch # type: ignore[import-not-found] # consumer dependency; not a ruthless dep
|
|
70
|
+
|
|
71
|
+
self._available_cached = bool(torch.cuda.is_available())
|
|
72
|
+
except ImportError:
|
|
73
|
+
self._available_cached = False
|
|
74
|
+
return self._available_cached
|