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.
Files changed (42) hide show
  1. ruthless/__init__.py +74 -0
  2. ruthless/_io.py +37 -0
  3. ruthless/_logging.py +10 -0
  4. ruthless/backend.py +34 -0
  5. ruthless/backends/__init__.py +67 -0
  6. ruthless/backends/base.py +55 -0
  7. ruthless/backends/docker.py +20 -0
  8. ruthless/backends/hf_jobs.py +215 -0
  9. ruthless/backends/local_cuda.py +74 -0
  10. ruthless/backends/pool.py +91 -0
  11. ruthless/backends/remote_ssh.py +258 -0
  12. ruthless/backends/remote_worker.py +107 -0
  13. ruthless/cli.py +58 -0
  14. ruthless/config/__init__.py +55 -0
  15. ruthless/config/common.py +120 -0
  16. ruthless/config/space.py +51 -0
  17. ruthless/config/strategies.py +81 -0
  18. ruthless/errors.py +33 -0
  19. ruthless/guards.py +12 -0
  20. ruthless/objective.py +38 -0
  21. ruthless/parallel.py +42 -0
  22. ruthless/remote.py +64 -0
  23. ruthless/report.py +49 -0
  24. ruthless/result.py +52 -0
  25. ruthless/strategies/__init__.py +0 -0
  26. ruthless/strategies/evolve_/__init__.py +8 -0
  27. ruthless/strategies/evolve_/evaluator.py +163 -0
  28. ruthless/strategies/evolve_/sandbox.py +604 -0
  29. ruthless/strategies/evolve_/strategy.py +391 -0
  30. ruthless/strategies/optuna_/__init__.py +8 -0
  31. ruthless/strategies/optuna_/strategy.py +124 -0
  32. ruthless/strategies/random_/__init__.py +7 -0
  33. ruthless/strategies/random_/strategy.py +71 -0
  34. ruthless/strategy.py +22 -0
  35. ruthless/testing.py +44 -0
  36. ruthless/wire.py +39 -0
  37. ruthless_efficiency-0.2.0.dist-info/METADATA +125 -0
  38. ruthless_efficiency-0.2.0.dist-info/RECORD +42 -0
  39. ruthless_efficiency-0.2.0.dist-info/WHEEL +4 -0
  40. ruthless_efficiency-0.2.0.dist-info/entry_points.txt +2 -0
  41. ruthless_efficiency-0.2.0.dist-info/licenses/LICENSE +21 -0
  42. 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