rootstock 0.5.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.
rootstock/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """
2
+ Rootstock: MLIP calculators with isolated Python environments.
3
+
4
+ This package provides ASE-compatible calculators that run MLIPs in isolated
5
+ subprocess environments, communicating via the i-PI protocol.
6
+ """
7
+
8
+ from .calculator import RootstockCalculator
9
+ from .clusters import CLUSTER_REGISTRY, KNOWN_ENVIRONMENTS, get_root_for_cluster
10
+ from .environment import (
11
+ EnvironmentManager,
12
+ get_model_cache_env,
13
+ list_built_environments,
14
+ list_environments,
15
+ )
16
+ from .pep723 import parse_pep723_metadata, validate_environment_file
17
+ from .server import RootstockServer
18
+ from .worker import run_worker
19
+
20
+ __all__ = [
21
+ "RootstockCalculator",
22
+ "RootstockServer",
23
+ "EnvironmentManager",
24
+ "list_environments",
25
+ "list_built_environments",
26
+ "get_model_cache_env",
27
+ "parse_pep723_metadata",
28
+ "validate_environment_file",
29
+ "run_worker",
30
+ "CLUSTER_REGISTRY",
31
+ "KNOWN_ENVIRONMENTS",
32
+ "get_root_for_cluster",
33
+ ]
34
+ __version__ = "0.5.0"
@@ -0,0 +1,194 @@
1
+ """
2
+ ASE-compatible calculator that delegates to an MLIP worker process.
3
+
4
+ This is the main user-facing interface for Rootstock.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ from ase.calculators.calculator import Calculator, all_changes
14
+ from ase.stress import full_3x3_to_voigt_6_stress
15
+
16
+ from .clusters import get_root_for_cluster
17
+ from .server import RootstockServer
18
+
19
+
20
+ class RootstockCalculator(Calculator):
21
+ """
22
+ ASE calculator that runs MLIPs in a pre-built isolated environment.
23
+
24
+ This calculator:
25
+ 1. Spawns a worker process using a pre-built virtual environment
26
+ 2. Communicates via i-PI protocol over Unix sockets
27
+ 3. Keeps the worker alive across calculations (no startup overhead)
28
+
29
+ Example:
30
+ from ase.build import bulk
31
+ from rootstock import RootstockCalculator
32
+
33
+ atoms = bulk("Cu", "fcc", a=3.6) * (5, 5, 5)
34
+
35
+ with RootstockCalculator(
36
+ cluster="della",
37
+ model="mace",
38
+ checkpoint="medium",
39
+ device="cuda",
40
+ ) as calc:
41
+ atoms.calc = calc
42
+ print(atoms.get_potential_energy())
43
+
44
+ # Checkpoint defaults to environment's default if omitted
45
+ with RootstockCalculator(
46
+ cluster="della",
47
+ model="uma",
48
+ device="cuda",
49
+ ) as calc:
50
+ atoms.calc = calc
51
+ print(atoms.get_potential_energy())
52
+
53
+ Note:
54
+ Environments must be pre-built using `rootstock build` before use.
55
+ Run: rootstock build mace_env --root /path/to/rootstock
56
+ """
57
+
58
+ implemented_properties = ["energy", "free_energy", "forces", "stress"]
59
+
60
+ def __init__(
61
+ self,
62
+ model: str,
63
+ checkpoint: str | None = None,
64
+ cluster: str | None = None,
65
+ root: str | Path | None = None,
66
+ device: str = "cuda",
67
+ log=None,
68
+ **kwargs,
69
+ ):
70
+ """
71
+ Initialize the Rootstock calculator.
72
+
73
+ Args:
74
+ model: Environment family name (e.g. "mace", "uma", "tensornet").
75
+ Maps to {model}_env environment.
76
+ checkpoint: Specific checkpoint/weights to load. Passed to the
77
+ environment's setup() as the model argument. If omitted,
78
+ the environment's default is used.
79
+ cluster: Known cluster name ("modal", "della"). Mutually exclusive with root.
80
+ root: Path to rootstock directory. Mutually exclusive with cluster.
81
+ device: PyTorch device ("cuda", "cuda:0", "cpu")
82
+ log: Optional file object for logging
83
+ **kwargs: Additional arguments passed to ASE Calculator
84
+ """
85
+ super().__init__(**kwargs)
86
+
87
+ self.device = device
88
+ self.log = log
89
+
90
+ # Resolve root directory
91
+ if cluster is not None and root is not None:
92
+ raise ValueError("Cannot specify both 'cluster' and 'root'")
93
+
94
+ if cluster is not None:
95
+ self.root = get_root_for_cluster(cluster)
96
+ elif root is not None:
97
+ self.root = Path(root)
98
+ else:
99
+ raise ValueError("Must specify either 'cluster' or 'root'")
100
+
101
+ self.env_name = f"{model}_env"
102
+ self.model_arg = checkpoint or ""
103
+
104
+ # Verify environment is built
105
+ env_python = self.root / "envs" / self.env_name / "bin" / "python"
106
+ if not env_python.exists():
107
+ envs_dir = self.root / "envs"
108
+ if envs_dir.exists():
109
+ available = [p.name for p in envs_dir.iterdir() if p.is_dir()]
110
+ else:
111
+ available = []
112
+ raise RuntimeError(
113
+ f"Environment '{self.env_name}' not built at {self.root}/envs/{self.env_name}/\n"
114
+ f"Run: rootstock build {self.env_name} --root {self.root}\n"
115
+ f"Available environments: {available}"
116
+ )
117
+
118
+ # Generate unique socket name to avoid conflicts
119
+ self._socket_name = f"rootstock_{uuid.uuid4().hex[:8]}"
120
+ self._server: RootstockServer | None = None
121
+
122
+ def _ensure_server(self):
123
+ """Start server if not already running."""
124
+ if self._server is None:
125
+ self._server = RootstockServer(
126
+ env_name=self.env_name,
127
+ model=self.model_arg,
128
+ device=self.device,
129
+ socket_name=self._socket_name,
130
+ root=self.root,
131
+ log=self.log,
132
+ )
133
+ self._server.start()
134
+
135
+ def calculate(
136
+ self,
137
+ atoms=None,
138
+ properties=None,
139
+ system_changes=all_changes,
140
+ ):
141
+ """
142
+ Calculate properties for the given atoms.
143
+
144
+ This is called by ASE when properties are requested.
145
+ """
146
+ if properties is None:
147
+ properties = self.implemented_properties
148
+
149
+ # Call parent to set self.atoms
150
+ Calculator.calculate(self, atoms, properties, system_changes)
151
+
152
+ # Ensure server is running
153
+ self._ensure_server()
154
+
155
+ # Get results from worker
156
+ energy, forces, virial = self._server.calculate(
157
+ positions=self.atoms.positions,
158
+ cell=np.array(self.atoms.cell),
159
+ atomic_numbers=self.atoms.numbers,
160
+ pbc=list(self.atoms.pbc),
161
+ )
162
+
163
+ # Store results
164
+ self.results["energy"] = energy
165
+ self.results["free_energy"] = energy # No entropy contribution
166
+ self.results["forces"] = forces
167
+
168
+ # Convert virial to stress if cell is 3D
169
+ if self.atoms.cell.rank == 3 and any(self.atoms.pbc):
170
+ volume = self.atoms.get_volume()
171
+ stress_tensor = -virial / volume
172
+ self.results["stress"] = full_3x3_to_voigt_6_stress(stress_tensor)
173
+ else:
174
+ self.results["stress"] = np.zeros(6)
175
+
176
+ def close(self):
177
+ """Stop the worker process and clean up."""
178
+ if self._server is not None:
179
+ self._server.stop()
180
+ self._server = None
181
+
182
+ def __enter__(self):
183
+ return self
184
+
185
+ def __exit__(self, exc_type, exc_val, exc_tb):
186
+ self.close()
187
+ return False
188
+
189
+ def __del__(self):
190
+ # Best-effort cleanup
191
+ try:
192
+ self.close()
193
+ except Exception:
194
+ pass
rootstock/cli.py ADDED
@@ -0,0 +1,426 @@
1
+ """
2
+ Rootstock CLI.
3
+
4
+ Commands:
5
+ rootstock build <env_name> --root <path> [--models m1,m2] [--force]
6
+ rootstock build --all --root <path>
7
+ rootstock status --root <path>
8
+ rootstock register <env_file> --root <path>
9
+ rootstock list --root <path>
10
+ """
11
+
12
+ import argparse
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ from pathlib import Path
19
+
20
+
21
+ def extract_minimum_python_version(requires_python: str) -> str:
22
+ """
23
+ Extract minimum Python version from a requires-python specifier.
24
+
25
+ Handles PEP 440 version specifiers like:
26
+ ">=3.10" -> "3.10"
27
+ ">=3.10,<3.13" -> "3.10"
28
+ "~=3.10" -> "3.10"
29
+ ">=3.10.0" -> "3.10" (normalized for uv)
30
+
31
+ Args:
32
+ requires_python: PEP 440 version specifier string
33
+
34
+ Returns:
35
+ Minimum version string suitable for `uv venv --python X.Y`
36
+
37
+ Raises:
38
+ ValueError: If no minimum version can be determined
39
+ """
40
+ from packaging.specifiers import SpecifierSet
41
+ from packaging.version import Version
42
+
43
+ spec_set = SpecifierSet(requires_python)
44
+
45
+ min_version = None
46
+
47
+ for spec in spec_set:
48
+ # Operators that establish a lower bound
49
+ if spec.operator in (">=", "~=", "=="):
50
+ version = Version(spec.version)
51
+ if min_version is None or version < min_version:
52
+ min_version = version
53
+ elif spec.operator == ">":
54
+ # Strict greater-than: we can't determine exact minimum
55
+ # but the version given is a reasonable approximation for uv
56
+ version = Version(spec.version)
57
+ if min_version is None or version < min_version:
58
+ min_version = version
59
+
60
+ if min_version is None:
61
+ raise ValueError(
62
+ f"Cannot determine minimum Python version from '{requires_python}'. "
63
+ "Specifier must include >=, ~=, ==, or > constraint."
64
+ )
65
+
66
+ # Return major.minor only (uv expects "3.10" not "3.10.0")
67
+ return f"{min_version.major}.{min_version.minor}"
68
+
69
+
70
+ def cmd_build(args) -> int:
71
+ """
72
+ Build a pre-built virtual environment from an environment source file.
73
+
74
+ Exit codes:
75
+ 0: Success
76
+ 1: Build failed
77
+ """
78
+ from .environment import check_uv_available, get_model_cache_env
79
+ from .pep723 import parse_pep723_metadata
80
+
81
+ root = Path(args.root)
82
+ env_name = args.env_name
83
+
84
+ # Check uv is available
85
+ if not check_uv_available():
86
+ print(
87
+ "Error: uv not found in PATH. Install uv: "
88
+ "https://docs.astral.sh/uv/getting-started/installation/",
89
+ file=sys.stderr,
90
+ )
91
+ return 1
92
+
93
+ # Find environment source file
94
+ env_source = root / "environments" / f"{env_name}.py"
95
+ if not env_source.exists():
96
+ print(f"Error: Environment source not found: {env_source}", file=sys.stderr)
97
+ available = (
98
+ list((root / "environments").glob("*.py")) if (root / "environments").exists() else []
99
+ )
100
+ if available:
101
+ print(f"Available: {[p.stem for p in available]}", file=sys.stderr)
102
+ return 1
103
+
104
+ env_target = root / "envs" / env_name
105
+
106
+ # Check if already exists
107
+ if env_target.exists():
108
+ if args.force:
109
+ print(f"Removing existing environment: {env_target}")
110
+ shutil.rmtree(env_target)
111
+ else:
112
+ print(f"Error: Environment already exists: {env_target}", file=sys.stderr)
113
+ print("Use --force to rebuild", file=sys.stderr)
114
+ return 1
115
+
116
+ print(f"Building environment: {env_name}")
117
+ print(f" Source: {env_source}")
118
+ print(f" Target: {env_target}")
119
+
120
+ # Parse PEP 723 metadata
121
+ content = env_source.read_text()
122
+ metadata = parse_pep723_metadata(content)
123
+ if metadata is None:
124
+ print(f"Error: No PEP 723 metadata in {env_source}", file=sys.stderr)
125
+ return 1
126
+
127
+ dependencies = metadata.get("dependencies", [])
128
+ requires_python = metadata.get("requires-python", ">=3.10")
129
+
130
+ # Extract uv-specific config (generic, works for any environment)
131
+ uv_config = metadata.get("tool", {}).get("uv", {})
132
+ find_links = uv_config.get("find-links", [])
133
+
134
+ # Extract minimum version properly
135
+ try:
136
+ python_version = extract_minimum_python_version(requires_python)
137
+ except ValueError as e:
138
+ print(f"Error: {e}", file=sys.stderr)
139
+ return 1
140
+
141
+ print(f" Python: {requires_python} -> {python_version}")
142
+ print(f" Dependencies: {dependencies}")
143
+ if find_links:
144
+ print(f" Find-links: {find_links}")
145
+
146
+ # Ensure home directory exists for model downloads
147
+ home_dir = root / "home"
148
+ home_dir.mkdir(parents=True, exist_ok=True)
149
+
150
+ # Set up environment for uv commands.
151
+ # UV_PYTHON_INSTALL_DIR ensures Python interpreters are stored in the rootstock
152
+ # root directory, making the entire installation portable across machines/containers.
153
+ python_install_dir = root / ".python"
154
+ python_install_dir.mkdir(parents=True, exist_ok=True)
155
+
156
+ # Create virtual environment
157
+ print("\n1. Creating virtual environment...")
158
+ with tempfile.TemporaryDirectory() as tmp_dir:
159
+ tmp_python_dir = Path(tmp_dir) / ".python"
160
+
161
+ # Download Python to local temp directory
162
+ download_env = os.environ.copy()
163
+ download_env["UV_PYTHON_INSTALL_DIR"] = str(tmp_python_dir)
164
+
165
+ result = subprocess.run(
166
+ ["uv", "python", "install", python_version],
167
+ capture_output=True,
168
+ text=True,
169
+ env=download_env,
170
+ )
171
+ if result.returncode != 0:
172
+ print(f"Error downloading Python: {result.stderr}", file=sys.stderr)
173
+ return 1
174
+
175
+ # Copy downloaded Python to root directory (if not already there)
176
+ if tmp_python_dir.exists():
177
+ for item in tmp_python_dir.iterdir():
178
+ dest = python_install_dir / item.name
179
+ if not dest.exists():
180
+ if item.is_dir():
181
+ print(f" Copying Python to {dest}")
182
+ shutil.copytree(item, dest)
183
+ else:
184
+ shutil.copy2(item, dest)
185
+
186
+ # Phase 2: Create venv using the Python we just installed
187
+ uv_env = os.environ.copy()
188
+ uv_env["UV_PYTHON_INSTALL_DIR"] = str(python_install_dir)
189
+
190
+ result = subprocess.run(
191
+ ["uv", "venv", str(env_target), "--python", python_version],
192
+ capture_output=True,
193
+ text=True,
194
+ env=uv_env,
195
+ )
196
+ if result.returncode != 0:
197
+ print(f"Error creating venv: {result.stderr}", file=sys.stderr)
198
+ return 1
199
+
200
+ env_python = env_target / "bin" / "python"
201
+
202
+ # Install dependencies using uv pip with --python flag
203
+ print("2. Installing dependencies...")
204
+
205
+ if dependencies:
206
+ pip_cmd = ["uv", "pip", "install", "--python", str(env_python)]
207
+ for link in find_links:
208
+ pip_cmd.extend(["--find-links", link])
209
+ pip_cmd.extend(dependencies)
210
+
211
+ result = subprocess.run(
212
+ pip_cmd,
213
+ capture_output=not args.verbose,
214
+ text=True,
215
+ env=uv_env,
216
+ )
217
+ if result.returncode != 0:
218
+ print(
219
+ f"Error installing dependencies: {result.stderr if not args.verbose else ''}",
220
+ file=sys.stderr,
221
+ )
222
+ return 1
223
+
224
+ # Install rootstock
225
+ print("3. Installing rootstock...")
226
+ # Find rootstock package path
227
+ import rootstock
228
+
229
+ rootstock_path = Path(rootstock.__file__).parent.parent
230
+
231
+ result = subprocess.run(
232
+ ["uv", "pip", "install", "--python", str(env_python), str(rootstock_path)],
233
+ capture_output=not args.verbose,
234
+ text=True,
235
+ env=uv_env,
236
+ )
237
+ if result.returncode != 0:
238
+ print(
239
+ f"Error installing rootstock: {result.stderr if not args.verbose else ''}",
240
+ file=sys.stderr,
241
+ )
242
+ return 1
243
+
244
+ # Copy environment source file
245
+ print("4. Copying environment source...")
246
+ shutil.copy(env_source, env_target / "env_source.py")
247
+
248
+ # Pre-download models if requested
249
+ if args.models:
250
+ models = [m.strip() for m in args.models.split(",")]
251
+ print(f"5. Pre-downloading models: {models}")
252
+
253
+ cache_env = get_model_cache_env(root)
254
+ env = {**os.environ, **cache_env}
255
+
256
+ for model in models:
257
+ print(f" Downloading: {model}")
258
+ script = f'''
259
+ import sys
260
+ sys.path.insert(0, "{env_target}")
261
+ from env_source import setup
262
+ calc = setup("{model}", "cpu")
263
+ print(f"Downloaded model: {model}")
264
+ '''
265
+ result = subprocess.run(
266
+ [str(env_python), "-c", script],
267
+ env=env,
268
+ capture_output=not args.verbose,
269
+ text=True,
270
+ )
271
+ if result.returncode != 0:
272
+ print(f" Warning: Failed to download {model}", file=sys.stderr)
273
+ if args.verbose:
274
+ print(result.stderr, file=sys.stderr)
275
+
276
+ print(f"\nBuilt environment: {env_target}")
277
+ return 0
278
+
279
+
280
+ def cmd_status(args) -> int:
281
+ """Show status of rootstock installation."""
282
+ from .environment import list_built_environments, list_environments
283
+
284
+ root = Path(args.root)
285
+
286
+ print(f"Rootstock root: {root}")
287
+
288
+ # List environment sources
289
+ print("\nEnvironment sources:")
290
+ sources = list_environments(root)
291
+ if not sources:
292
+ print(" (none)")
293
+ else:
294
+ for name, path in sources:
295
+ print(f" {name}")
296
+
297
+ # List built environments
298
+ print("\nBuilt environments:")
299
+ built = list_built_environments(root)
300
+ if not built:
301
+ print(" (none)")
302
+ else:
303
+ for name, path in built:
304
+ # Check if env_source.py exists
305
+ has_source = (path / "env_source.py").exists()
306
+ status = "ready" if has_source else "incomplete"
307
+ print(f" {name:<20} [{status}]")
308
+
309
+ # Show cache sizes
310
+ print("\nCache:")
311
+ cache_dir = root / "cache"
312
+ if cache_dir.exists():
313
+ for subdir in sorted(cache_dir.iterdir()):
314
+ if subdir.is_dir():
315
+ # Get size
316
+ total_size = sum(f.stat().st_size for f in subdir.rglob("*") if f.is_file())
317
+ size_mb = total_size / (1024 * 1024)
318
+ print(f" {subdir.name + '/':<20} {size_mb:.1f} MB")
319
+ else:
320
+ print(" (no cache directory)")
321
+
322
+ return 0
323
+
324
+
325
+ def cmd_register(args) -> int:
326
+ """Register an environment file to the shared directory."""
327
+ from .pep723 import validate_environment_file
328
+
329
+ env_path = Path(args.env_file)
330
+ root = Path(args.root)
331
+
332
+ # Validate the file
333
+ print(f"Validating {env_path}...")
334
+ is_valid, error = validate_environment_file(env_path)
335
+ if not is_valid:
336
+ print(f"Error: {error}", file=sys.stderr)
337
+ return 1
338
+
339
+ # Create environments directory
340
+ env_dir = root / "environments"
341
+ env_dir.mkdir(parents=True, exist_ok=True)
342
+
343
+ # Copy file
344
+ dest_path = env_dir / env_path.name
345
+ shutil.copy2(env_path, dest_path)
346
+
347
+ print(f"Registered: {env_path.stem} -> {dest_path}")
348
+ return 0
349
+
350
+
351
+ def cmd_list(args) -> int:
352
+ """List registered environments."""
353
+ from .environment import list_built_environments, list_environments
354
+
355
+ root = Path(args.root)
356
+
357
+ sources = list_environments(root)
358
+ built = list_built_environments(root)
359
+ built_names = {name for name, _ in built}
360
+
361
+ if not sources and not built:
362
+ print(f"No environments in {root}")
363
+ return 0
364
+
365
+ print(f"Environments in {root}:")
366
+ for name, path in sources:
367
+ status = "built" if name in built_names else "source only"
368
+ print(f" {name:<20} [{status}]")
369
+
370
+ return 0
371
+
372
+
373
+ def main():
374
+ parser = argparse.ArgumentParser(
375
+ prog="rootstock",
376
+ description="Rootstock MLIP environment manager",
377
+ )
378
+ subparsers = parser.add_subparsers(dest="command", required=True)
379
+
380
+ # build command
381
+ build_parser = subparsers.add_parser(
382
+ "build",
383
+ help="Build a pre-built environment",
384
+ description="Build a virtual environment from an environment source file.",
385
+ )
386
+ build_parser.add_argument("env_name", help="Name of environment to build (e.g., mace_env)")
387
+ build_parser.add_argument("--root", required=True, help="Root directory")
388
+ build_parser.add_argument("--models", help="Comma-separated list of models to pre-download")
389
+ build_parser.add_argument("--force", action="store_true", help="Rebuild if exists")
390
+ build_parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
391
+ build_parser.set_defaults(func=cmd_build)
392
+
393
+ # status command
394
+ status_parser = subparsers.add_parser(
395
+ "status",
396
+ help="Show status of rootstock installation",
397
+ description="Show environment sources, built environments, and cache sizes.",
398
+ )
399
+ status_parser.add_argument("--root", required=True, help="Root directory")
400
+ status_parser.set_defaults(func=cmd_status)
401
+
402
+ # register command
403
+ reg_parser = subparsers.add_parser(
404
+ "register",
405
+ help="Register an environment file",
406
+ description="Copy a validated environment file to the shared environments directory.",
407
+ )
408
+ reg_parser.add_argument("env_file", help="Path to environment file")
409
+ reg_parser.add_argument("--root", required=True, help="Root directory")
410
+ reg_parser.set_defaults(func=cmd_register)
411
+
412
+ # list command
413
+ list_parser = subparsers.add_parser(
414
+ "list",
415
+ help="List registered environments",
416
+ description="List all environment files in the shared environments directory.",
417
+ )
418
+ list_parser.add_argument("--root", required=True, help="Root directory")
419
+ list_parser.set_defaults(func=cmd_list)
420
+
421
+ args = parser.parse_args()
422
+ sys.exit(args.func(args))
423
+
424
+
425
+ if __name__ == "__main__":
426
+ main()
rootstock/clusters.py ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ Cluster configuration for Rootstock.
3
+
4
+ This module provides mappings from cluster names to root directories
5
+ where Rootstock environments and caches are stored.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ # Registry of known clusters and their rootstock root directories
13
+ CLUSTER_REGISTRY: dict[str, str] = {
14
+ "modal": "/vol/rootstock",
15
+ "della": "/scratch/gpfs/SHARED/rootstock",
16
+ }
17
+
18
+ # Known environment families (used for validation)
19
+ KNOWN_ENVIRONMENTS = ["mace", "chgnet", "orb", "alignn", "uma", "tensornet"]
20
+
21
+
22
+ def get_root_for_cluster(cluster: str) -> Path:
23
+ """
24
+ Get the rootstock root directory for a known cluster.
25
+
26
+ Args:
27
+ cluster: Name of a known cluster (e.g., "modal", "della")
28
+
29
+ Returns:
30
+ Path to the rootstock root directory for that cluster.
31
+
32
+ Raises:
33
+ ValueError: If the cluster is not in the registry.
34
+ """
35
+ if cluster not in CLUSTER_REGISTRY:
36
+ available = ", ".join(CLUSTER_REGISTRY.keys())
37
+ raise ValueError(
38
+ f"Unknown cluster '{cluster}'. Known clusters: {available}. "
39
+ f"Use root='/path/to/rootstock' for custom locations."
40
+ )
41
+ return Path(CLUSTER_REGISTRY[cluster])