algorithm-nexus 0.2.1__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.
- algorithm_nexus/__init__.py +4 -0
- algorithm_nexus/cli.py +101 -0
- algorithm_nexus/commands/__init__.py +6 -0
- algorithm_nexus/commands/ado_validator.py +176 -0
- algorithm_nexus/commands/benchmark_manager.py +1264 -0
- algorithm_nexus/commands/get.py +120 -0
- algorithm_nexus/commands/list.py +349 -0
- algorithm_nexus/commands/run.py +162 -0
- algorithm_nexus/commands/utils.py +584 -0
- algorithm_nexus/commands/validate.py +683 -0
- algorithm_nexus/commands/venv_manager.py +158 -0
- algorithm_nexus/models.py +408 -0
- algorithm_nexus-0.2.1.dist-info/METADATA +319 -0
- algorithm_nexus-0.2.1.dist-info/RECORD +18 -0
- algorithm_nexus-0.2.1.dist-info/WHEEL +5 -0
- algorithm_nexus-0.2.1.dist-info/entry_points.txt +2 -0
- algorithm_nexus-0.2.1.dist-info/licenses/LICENSE +201 -0
- algorithm_nexus-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
# Copyright IBM Corp. 2026
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Benchmark manager for discovering and managing benchmark instances from GitHub PRs."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import typer
|
|
17
|
+
import yaml
|
|
18
|
+
from ado.core.discoveryspace.config import DiscoverySpaceConfiguration
|
|
19
|
+
from ado.core.operation.config import (
|
|
20
|
+
ConfigurationMetadata,
|
|
21
|
+
DiscoveryOperationConfiguration,
|
|
22
|
+
DiscoveryOperationEnum,
|
|
23
|
+
DiscoveryOperationResourceConfiguration,
|
|
24
|
+
OperatorReference,
|
|
25
|
+
)
|
|
26
|
+
from ado.core.remotecontext.config import (
|
|
27
|
+
PackageConfiguration,
|
|
28
|
+
RemoteExecutionContext,
|
|
29
|
+
)
|
|
30
|
+
from ado.modules.operators.randomwalk import (
|
|
31
|
+
BaseSamplerConfiguration,
|
|
32
|
+
RandomWalkParameters,
|
|
33
|
+
)
|
|
34
|
+
from ado.utilities.output import pydantic_model_as_yaml
|
|
35
|
+
from rich.console import Console
|
|
36
|
+
except ImportError:
|
|
37
|
+
print(
|
|
38
|
+
"Error: CLI dependencies are not installed.\n"
|
|
39
|
+
"Please install them with: pip install algorithm-nexus[cli]",
|
|
40
|
+
file=sys.stderr,
|
|
41
|
+
)
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
from algorithm_nexus.commands.utils import strip_ansi_codes
|
|
45
|
+
from algorithm_nexus.models import (
|
|
46
|
+
AlgorithmNexusPackageConfig,
|
|
47
|
+
BenchmarkExecutionResult,
|
|
48
|
+
ValidationReport,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
console = Console()
|
|
52
|
+
console_err = Console(stderr=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Random walk operation template using DiscoveryOperationResourceConfiguration
|
|
56
|
+
def create_random_walk_operation_config(
|
|
57
|
+
space_id: str,
|
|
58
|
+
metadata_name: str = "randomwalk-all",
|
|
59
|
+
metadata_description: str = "Perform a random walk on all points in a space",
|
|
60
|
+
custom_metadata: dict[str, Any] | None = None,
|
|
61
|
+
actuator_configuration_ids: list[str] | None = None,
|
|
62
|
+
) -> DiscoveryOperationResourceConfiguration:
|
|
63
|
+
"""Create a random walk operation configuration.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
space_id: The discovery space identifier
|
|
67
|
+
metadata_name: Name for the operation metadata
|
|
68
|
+
metadata_description: Description for the operation metadata
|
|
69
|
+
custom_metadata: Additional custom metadata fields
|
|
70
|
+
actuator_configuration_ids: Optional list of actuator configuration identifiers
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
DiscoveryOperationResourceConfiguration object
|
|
74
|
+
"""
|
|
75
|
+
# Build metadata with custom fields
|
|
76
|
+
labels = {}
|
|
77
|
+
if custom_metadata:
|
|
78
|
+
labels.update(custom_metadata)
|
|
79
|
+
metadata = ConfigurationMetadata(
|
|
80
|
+
name=metadata_name,
|
|
81
|
+
description=metadata_description,
|
|
82
|
+
labels=labels or None,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
operation = DiscoveryOperationConfiguration(
|
|
86
|
+
module=OperatorReference(
|
|
87
|
+
operatorName="random_walk",
|
|
88
|
+
operationType=DiscoveryOperationEnum.EXPLORE,
|
|
89
|
+
),
|
|
90
|
+
parameters=RandomWalkParameters(
|
|
91
|
+
numberEntities="all",
|
|
92
|
+
singleMeasurement=True,
|
|
93
|
+
samplerConfig=BaseSamplerConfiguration(
|
|
94
|
+
samplerType="generator",
|
|
95
|
+
mode="sequential",
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return DiscoveryOperationResourceConfiguration(
|
|
101
|
+
metadata=metadata,
|
|
102
|
+
spaces=[space_id],
|
|
103
|
+
operation=operation,
|
|
104
|
+
actuatorConfigurationIdentifiers=actuator_configuration_ids or [],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class BenchmarkManager:
|
|
109
|
+
"""Manages discovery and execution of benchmark instances from a GitHub PR."""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
pr_url: str | None,
|
|
114
|
+
execute: bool = True,
|
|
115
|
+
remote_context_file: Path | None = None,
|
|
116
|
+
context_file: Path | None = None,
|
|
117
|
+
actuator_configuration_id_map: dict[str, str] | None = None,
|
|
118
|
+
):
|
|
119
|
+
"""Initialize the benchmark manager.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
pr_url: GitHub Pull Request URL, or None for non-PR mode
|
|
123
|
+
execute: Whether to execute benchmarks with ADO CLI
|
|
124
|
+
remote_context_file: Path to remote execution context YAML file
|
|
125
|
+
context_file: Path to ADO context YAML file (samplestore context)
|
|
126
|
+
actuator_configuration_id_map: Optional mapping of actuatorIdentifier to
|
|
127
|
+
actuatorConfigurationId. When provided, experiments whose actuatorIdentifier
|
|
128
|
+
matches a key will have the corresponding actuatorConfigurationId added to
|
|
129
|
+
the operation's actuatorConfigurationIdentifiers list.
|
|
130
|
+
"""
|
|
131
|
+
self.pr_url = pr_url
|
|
132
|
+
self.execute = execute
|
|
133
|
+
self.remote_context_file = remote_context_file
|
|
134
|
+
self.context_file = context_file
|
|
135
|
+
self.actuator_configuration_id_map: dict[str, str] = (
|
|
136
|
+
actuator_configuration_id_map or {}
|
|
137
|
+
)
|
|
138
|
+
self.repo_root = Path.cwd()
|
|
139
|
+
self.temp_dir_obj = None
|
|
140
|
+
|
|
141
|
+
def get_changed_files(self) -> list[str]:
|
|
142
|
+
"""Get list of changed files from the PR using gh CLI.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
List of changed file paths
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
result = subprocess.run( # noqa: S603
|
|
149
|
+
["gh", "pr", "diff", self.pr_url, "--name-only"], # noqa: S607
|
|
150
|
+
capture_output=True,
|
|
151
|
+
text=True,
|
|
152
|
+
check=True,
|
|
153
|
+
)
|
|
154
|
+
return [line.strip() for line in result.stdout.split("\n") if line.strip()]
|
|
155
|
+
except subprocess.CalledProcessError as e:
|
|
156
|
+
console_err.print(f"[red]Error:[/red] Failed to fetch PR diff: {e}")
|
|
157
|
+
console_err.print("Make sure 'gh' is installed and authenticated")
|
|
158
|
+
raise typer.Exit(code=1)
|
|
159
|
+
except FileNotFoundError:
|
|
160
|
+
console_err.print("[red]Error:[/red] GitHub CLI (gh) is not installed")
|
|
161
|
+
console_err.print("Install it from: https://cli.github.com/")
|
|
162
|
+
raise typer.Exit(code=1)
|
|
163
|
+
|
|
164
|
+
def _discover_instances(
|
|
165
|
+
self,
|
|
166
|
+
packages_root: Path | None = None,
|
|
167
|
+
package_filter: str | None = None,
|
|
168
|
+
) -> list[Path]:
|
|
169
|
+
"""Discover benchmark instances based on mode (PR or all/package).
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
packages_root: Path to packages directory (for all/package mode)
|
|
173
|
+
package_filter: Optional package name to filter by
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
List of benchmark instance paths
|
|
177
|
+
"""
|
|
178
|
+
if self.pr_url:
|
|
179
|
+
# PR mode: Check out PR code if needed
|
|
180
|
+
if not self.is_local_repo_on_pr_commit():
|
|
181
|
+
console.print("[yellow]Local repository is not on PR commit[/yellow]")
|
|
182
|
+
console.print("Checking out PR code to temporary directory...")
|
|
183
|
+
self.checkout_pr_to_temp()
|
|
184
|
+
else:
|
|
185
|
+
console.print(
|
|
186
|
+
"[green]✓[/green] Using local repository (already on PR commit)"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
changed_files = self.get_changed_files()
|
|
190
|
+
return self.find_benchmark_instances(changed_files)
|
|
191
|
+
|
|
192
|
+
elif packages_root:
|
|
193
|
+
# All or package mode
|
|
194
|
+
console.print("Discovering benchmark instances...")
|
|
195
|
+
return self.find_all_benchmark_instances(packages_root, package_filter)
|
|
196
|
+
|
|
197
|
+
else:
|
|
198
|
+
console.print(
|
|
199
|
+
"[red]Error:[/red] Either pr_url or packages_root must be provided"
|
|
200
|
+
)
|
|
201
|
+
raise typer.Exit(code=1)
|
|
202
|
+
|
|
203
|
+
def _print_mode_header(
|
|
204
|
+
self,
|
|
205
|
+
packages_root: Path | None = None,
|
|
206
|
+
package_filter: str | None = None,
|
|
207
|
+
mode_name: str = "execution",
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Print mode header and configuration.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
packages_root: Path to packages directory (for all/package mode)
|
|
213
|
+
package_filter: Optional package name to filter by
|
|
214
|
+
mode_name: Name of the mode (e.g., "Executing", "Validating")
|
|
215
|
+
"""
|
|
216
|
+
if self.pr_url:
|
|
217
|
+
console.print(f"\n[bold]Mode:[/bold] {mode_name} PR changes")
|
|
218
|
+
console.print(f"PR URL: {self.pr_url}")
|
|
219
|
+
elif package_filter:
|
|
220
|
+
console.print(
|
|
221
|
+
f"\n[bold]Mode:[/bold] {mode_name} package '{package_filter}'"
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
console.print(f"\n[bold]Mode:[/bold] {mode_name} all benchmark instances")
|
|
225
|
+
|
|
226
|
+
# Print packages root when not in PR mode
|
|
227
|
+
if not self.pr_url and packages_root:
|
|
228
|
+
console.print(f"Packages root: {packages_root.resolve()}")
|
|
229
|
+
|
|
230
|
+
console.print("=" * 60)
|
|
231
|
+
|
|
232
|
+
def _print_instances_found(
|
|
233
|
+
self,
|
|
234
|
+
benchmark_instances: list[Path],
|
|
235
|
+
package_filter: str | None = None,
|
|
236
|
+
show_list: bool = True,
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Print found instances or empty message.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
benchmark_instances: List of found benchmark instances
|
|
242
|
+
package_filter: Optional package name (for empty message)
|
|
243
|
+
show_list: Whether to show the list of instances
|
|
244
|
+
"""
|
|
245
|
+
if not benchmark_instances:
|
|
246
|
+
if self.pr_url:
|
|
247
|
+
console.print(
|
|
248
|
+
"\n[yellow]No benchmark instances found in this PR.[/yellow]"
|
|
249
|
+
)
|
|
250
|
+
elif package_filter:
|
|
251
|
+
console.print(
|
|
252
|
+
f"\n[yellow]No benchmark instances found in package '{package_filter}'.[/yellow]"
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
console.print("\n[yellow]No benchmark instances found.[/yellow]")
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
console.print(
|
|
259
|
+
f"\n[bold]Found {len(benchmark_instances)} benchmark instance(s):[/bold]"
|
|
260
|
+
)
|
|
261
|
+
if show_list:
|
|
262
|
+
for instance in benchmark_instances:
|
|
263
|
+
console.print(f" • {instance}")
|
|
264
|
+
|
|
265
|
+
def _resolve_all_dependencies(
|
|
266
|
+
self, benchmark_instances: list[Path], verbose: bool = False
|
|
267
|
+
) -> dict[Path, list[str]]:
|
|
268
|
+
"""Resolve dependencies for all benchmark instances.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
benchmark_instances: List of benchmark instance paths
|
|
272
|
+
verbose: Whether to show verbose output
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Dictionary mapping instance paths to their dependency lists
|
|
276
|
+
"""
|
|
277
|
+
console.print("\n[bold]Resolving dependencies...[/bold]")
|
|
278
|
+
instance_dependencies: dict[Path, list[str]] = {}
|
|
279
|
+
|
|
280
|
+
for instance in benchmark_instances:
|
|
281
|
+
packages = self.get_benchmark_packages_for_instance(instance)
|
|
282
|
+
if verbose:
|
|
283
|
+
console.print(
|
|
284
|
+
f" {instance}: {', '.join(packages) if packages else 'no dependencies'}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
instance_dependencies[instance] = list(packages)
|
|
288
|
+
|
|
289
|
+
console.print(
|
|
290
|
+
f"[green]✓[/green] Resolved dependencies for {len(benchmark_instances)} instance(s)"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return instance_dependencies
|
|
294
|
+
|
|
295
|
+
def _parse_instance_path(self, instance_path: Path) -> tuple[str, str, str]:
|
|
296
|
+
"""Parse benchmark instance path to extract package, model, and instance names.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
instance_path: Path to benchmark instance directory
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Tuple of (package_name, model_name, instance_name)
|
|
303
|
+
For package-level instances, model_name will be "base"
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
ValueError: If the instance path format is invalid
|
|
307
|
+
"""
|
|
308
|
+
# Parse instance path using regex:
|
|
309
|
+
# - Model-level: packages/<package>/models/<model>/benchmark_instances/<instance>
|
|
310
|
+
# - Package-level: packages/<package>/benchmark_instances/<instance>
|
|
311
|
+
path_str = str(instance_path)
|
|
312
|
+
|
|
313
|
+
# Try model-level pattern first
|
|
314
|
+
model_pattern = r"^packages/([^/]+)/models/([^/]+)/benchmark_instances/([^/]+)$"
|
|
315
|
+
match = re.match(model_pattern, path_str)
|
|
316
|
+
if match:
|
|
317
|
+
package_name, model_name, instance_name = match.groups()
|
|
318
|
+
return package_name, model_name, instance_name
|
|
319
|
+
|
|
320
|
+
# Try package-level pattern
|
|
321
|
+
package_pattern = r"^packages/([^/]+)/benchmark_instances/([^/]+)$"
|
|
322
|
+
match = re.match(package_pattern, path_str)
|
|
323
|
+
if match:
|
|
324
|
+
package_name, instance_name = match.groups()
|
|
325
|
+
model_name = "base"
|
|
326
|
+
return package_name, model_name, instance_name
|
|
327
|
+
|
|
328
|
+
# If neither pattern matches, raise an error
|
|
329
|
+
raise ValueError(
|
|
330
|
+
f"Invalid benchmark instance path format: {instance_path}. "
|
|
331
|
+
"Expected: packages/<package>/benchmark_instances/<instance> or "
|
|
332
|
+
"packages/<package>/models/<model>/benchmark_instances/<instance>"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def find_benchmark_instances(self, changed_files: list[str]) -> list[Path]:
|
|
336
|
+
"""Find benchmark instance directories from changed files.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
changed_files: List of changed file paths
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
List of unique benchmark instance directory paths
|
|
343
|
+
"""
|
|
344
|
+
benchmark_dirs = set()
|
|
345
|
+
|
|
346
|
+
for file_path in changed_files:
|
|
347
|
+
path = Path(file_path)
|
|
348
|
+
if "benchmark_instances" in path.parts:
|
|
349
|
+
# Find the index of 'benchmark_instances' in the path parts
|
|
350
|
+
bench_idx = path.parts.index("benchmark_instances")
|
|
351
|
+
# Ensure there's at least one part after 'benchmark_instances'
|
|
352
|
+
if bench_idx + 1 < len(path.parts):
|
|
353
|
+
# Reconstruct path up to and including the first directory after 'benchmark_instances'
|
|
354
|
+
instance_dir = Path(*path.parts[: bench_idx + 2])
|
|
355
|
+
benchmark_dirs.add(instance_dir)
|
|
356
|
+
|
|
357
|
+
return sorted(benchmark_dirs)
|
|
358
|
+
|
|
359
|
+
def checkout_pr_to_temp(self):
|
|
360
|
+
"""Clone the repository and checkout the PR code in a temporary directory."""
|
|
361
|
+
try:
|
|
362
|
+
self.temp_dir_obj = tempfile.TemporaryDirectory(prefix="pr_checkout_")
|
|
363
|
+
temp_path = Path(self.temp_dir_obj.name)
|
|
364
|
+
|
|
365
|
+
console.print(f"Creating temporary clone in {temp_path}")
|
|
366
|
+
|
|
367
|
+
# Use gh CLI to clone the repo directly from the PR URL
|
|
368
|
+
console.print("Cloning repository using gh CLI...")
|
|
369
|
+
subprocess.run( # noqa: S603
|
|
370
|
+
["gh", "repo", "clone", self.pr_url, str(temp_path)], # noqa: S607
|
|
371
|
+
capture_output=True,
|
|
372
|
+
text=True,
|
|
373
|
+
check=True,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# Use gh CLI to checkout the PR using the URL
|
|
377
|
+
console.print("Checking out PR using gh CLI...")
|
|
378
|
+
subprocess.run( # noqa: S603
|
|
379
|
+
["gh", "pr", "checkout", self.pr_url], # noqa: S607
|
|
380
|
+
capture_output=True,
|
|
381
|
+
text=True,
|
|
382
|
+
check=True,
|
|
383
|
+
cwd=temp_path,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
self.repo_root = temp_path
|
|
387
|
+
console.print("[green]✓[/green] Successfully checked out PR code")
|
|
388
|
+
|
|
389
|
+
except subprocess.CalledProcessError as e:
|
|
390
|
+
console_err.print(f"[red]Error:[/red] Failed to checkout PR: {e}")
|
|
391
|
+
if e.stderr:
|
|
392
|
+
console_err.print(f" {e.stderr}")
|
|
393
|
+
self.cleanup_temp_dir()
|
|
394
|
+
raise typer.Exit(code=1)
|
|
395
|
+
|
|
396
|
+
def cleanup_temp_dir(self):
|
|
397
|
+
"""Clean up the temporary directory if it was created."""
|
|
398
|
+
if self.temp_dir_obj:
|
|
399
|
+
try:
|
|
400
|
+
console.print("\nCleaning up temporary directory")
|
|
401
|
+
self.temp_dir_obj.cleanup()
|
|
402
|
+
self.temp_dir_obj = None
|
|
403
|
+
console.print("[green]✓[/green] Temporary directory cleaned up")
|
|
404
|
+
except Exception as e:
|
|
405
|
+
console_err.print(
|
|
406
|
+
f"[yellow]Warning:[/yellow] Failed to clean up temporary directory: {e}"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def is_local_repo_on_pr_commit(self) -> bool:
|
|
410
|
+
"""Check if the local repository is on the same commit as the PR head.
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
True if local repo is on PR commit, False otherwise
|
|
414
|
+
"""
|
|
415
|
+
try:
|
|
416
|
+
# Get PR head commit SHA
|
|
417
|
+
pr_number = self.pr_url.rstrip("/").split("/")[-1]
|
|
418
|
+
result = subprocess.run( # noqa: S603
|
|
419
|
+
[ # noqa: S607
|
|
420
|
+
"gh",
|
|
421
|
+
"pr",
|
|
422
|
+
"view",
|
|
423
|
+
pr_number,
|
|
424
|
+
"--json",
|
|
425
|
+
"headRefOid",
|
|
426
|
+
"--jq",
|
|
427
|
+
".headRefOid",
|
|
428
|
+
],
|
|
429
|
+
capture_output=True,
|
|
430
|
+
text=True,
|
|
431
|
+
check=True,
|
|
432
|
+
cwd=self.repo_root,
|
|
433
|
+
)
|
|
434
|
+
pr_commit = result.stdout.strip()
|
|
435
|
+
|
|
436
|
+
# Get current local commit SHA
|
|
437
|
+
result = subprocess.run( # noqa: S603
|
|
438
|
+
["git", "rev-parse", "HEAD"], # noqa: S607
|
|
439
|
+
capture_output=True,
|
|
440
|
+
text=True,
|
|
441
|
+
check=True,
|
|
442
|
+
cwd=self.repo_root,
|
|
443
|
+
)
|
|
444
|
+
local_commit = result.stdout.strip()
|
|
445
|
+
|
|
446
|
+
return pr_commit == local_commit
|
|
447
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
448
|
+
# If we can't determine, assume they're different to be safe
|
|
449
|
+
return False
|
|
450
|
+
|
|
451
|
+
def _resolve_benchmark_package_requirement(self, requirement: str) -> str:
|
|
452
|
+
"""Resolve a benchmark package requirement specifier to its final form.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
requirement: The requirement specifier from nexus.yaml
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
Resolved requirement string (local path or package specifier)
|
|
459
|
+
"""
|
|
460
|
+
# Check if it's a local path
|
|
461
|
+
resolved_path = self.repo_root / requirement.lstrip("./")
|
|
462
|
+
if resolved_path.exists():
|
|
463
|
+
return str(resolved_path)
|
|
464
|
+
|
|
465
|
+
# uv requires git+ prefix for GitHub HTTPS URLs
|
|
466
|
+
if requirement.startswith(("https://github.com/", "http://github.com/")):
|
|
467
|
+
return (
|
|
468
|
+
f"git+{requirement}"
|
|
469
|
+
if not requirement.startswith("git+")
|
|
470
|
+
else requirement
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# SSH shorthand (git@github.com:org/repo) must become git+ssh://git@github.com/org/repo
|
|
474
|
+
if requirement.startswith("git@github.com:"):
|
|
475
|
+
return requirement.replace(
|
|
476
|
+
"git@github.com:", "git+ssh://git@github.com/", 1
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
return requirement
|
|
480
|
+
|
|
481
|
+
def get_benchmark_packages_for_instance(self, instance_path: Path) -> set[str]:
|
|
482
|
+
"""Get the benchmark packages required for a specific benchmark instance.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
instance_path: Path to benchmark instance directory
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Set of requirement specifiers for benchmark packages
|
|
489
|
+
"""
|
|
490
|
+
packages = set()
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
repo_root = self.repo_root
|
|
494
|
+
|
|
495
|
+
space_yaml_path = repo_root / instance_path / "space.yaml"
|
|
496
|
+
if not space_yaml_path.is_file():
|
|
497
|
+
return set()
|
|
498
|
+
|
|
499
|
+
# Load space configuration using DiscoverySpaceConfiguration
|
|
500
|
+
space_config_dict = yaml.safe_load(space_yaml_path.read_text())
|
|
501
|
+
space_config = DiscoverySpaceConfiguration.model_validate(space_config_dict)
|
|
502
|
+
|
|
503
|
+
# Extract experiment identifiers
|
|
504
|
+
# Convert experiments to reference list for easier processing
|
|
505
|
+
space_config_with_refs = (
|
|
506
|
+
space_config.convert_experiments_to_reference_list()
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
if not space_config_with_refs.experiments:
|
|
510
|
+
return set()
|
|
511
|
+
|
|
512
|
+
# After conversion, experiments is a list of ExperimentReference
|
|
513
|
+
experiment_ids: set[str] = {
|
|
514
|
+
exp_ref.experimentIdentifier # type: ignore[union-attr]
|
|
515
|
+
for exp_ref in space_config_with_refs.experiments # type: ignore[union-attr]
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
current_path = repo_root / instance_path
|
|
519
|
+
nexus_yaml_path = None
|
|
520
|
+
|
|
521
|
+
# Find the deepest nexus.yaml file up to repo_root
|
|
522
|
+
for parent in [current_path, *current_path.parents]:
|
|
523
|
+
if parent == repo_root.parent:
|
|
524
|
+
break
|
|
525
|
+
potential_nexus = parent / "nexus.yaml"
|
|
526
|
+
if potential_nexus.is_file():
|
|
527
|
+
nexus_yaml_path = potential_nexus
|
|
528
|
+
break
|
|
529
|
+
|
|
530
|
+
if not nexus_yaml_path:
|
|
531
|
+
parts = instance_path.parts
|
|
532
|
+
if len(parts) > 0 and parts[0] == "packages" and len(parts) > 1:
|
|
533
|
+
potential_nexus = repo_root / "packages" / parts[1] / "nexus.yaml"
|
|
534
|
+
if potential_nexus.exists():
|
|
535
|
+
nexus_yaml_path = potential_nexus
|
|
536
|
+
|
|
537
|
+
if not nexus_yaml_path or not nexus_yaml_path.exists():
|
|
538
|
+
return set()
|
|
539
|
+
|
|
540
|
+
# Load nexus config using AlgorithmNexusPackageConfig
|
|
541
|
+
nexus_config_dict = yaml.safe_load(nexus_yaml_path.read_text())
|
|
542
|
+
nexus_config = AlgorithmNexusPackageConfig.model_validate(nexus_config_dict)
|
|
543
|
+
|
|
544
|
+
for bench_pkg in nexus_config.package.benchmark_packages:
|
|
545
|
+
pkg_experiments = set(bench_pkg.experiments)
|
|
546
|
+
if experiment_ids & pkg_experiments:
|
|
547
|
+
resolved_req = self._resolve_benchmark_package_requirement(
|
|
548
|
+
bench_pkg.requirement_specifier
|
|
549
|
+
)
|
|
550
|
+
packages.add(resolved_req)
|
|
551
|
+
|
|
552
|
+
return packages
|
|
553
|
+
|
|
554
|
+
except Exception as e:
|
|
555
|
+
console_err.print(
|
|
556
|
+
f"[yellow]Warning:[/yellow] Could not determine benchmark packages for {instance_path}: {e}"
|
|
557
|
+
)
|
|
558
|
+
return set()
|
|
559
|
+
|
|
560
|
+
def execute_benchmark(self, instance_path: Path) -> BenchmarkExecutionResult:
|
|
561
|
+
"""Execute a benchmark instance using ADO CLI.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
instance_path: Path to benchmark instance directory
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
Execution result
|
|
568
|
+
"""
|
|
569
|
+
result = BenchmarkExecutionResult(instance_path=str(instance_path))
|
|
570
|
+
|
|
571
|
+
# Prepare remote config once for this benchmark instance
|
|
572
|
+
remote_config_for_space = None
|
|
573
|
+
remote_config_for_operation = None
|
|
574
|
+
temp_remote_configs = []
|
|
575
|
+
|
|
576
|
+
try:
|
|
577
|
+
# Use repo_root which is set to either local or checked out directory
|
|
578
|
+
space_yaml_path = self.repo_root / instance_path / "space.yaml"
|
|
579
|
+
|
|
580
|
+
if not space_yaml_path.is_file():
|
|
581
|
+
raise FileNotFoundError(f"space.yaml not found in {instance_path}")
|
|
582
|
+
|
|
583
|
+
# If in remote mode, create remote configs with benchmark packages
|
|
584
|
+
if self.remote_context_file:
|
|
585
|
+
benchmark_packages = self.get_benchmark_packages_for_instance(
|
|
586
|
+
instance_path
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
if benchmark_packages:
|
|
590
|
+
console.print(
|
|
591
|
+
f" Installing benchmark packages in Ray environment: {', '.join(benchmark_packages)}"
|
|
592
|
+
)
|
|
593
|
+
# Create base remote config with benchmark packages
|
|
594
|
+
base_remote_config = self._create_or_update_remote_config(
|
|
595
|
+
benchmark_packages, self.remote_context_file, self.repo_root
|
|
596
|
+
)
|
|
597
|
+
temp_remote_configs.append(base_remote_config)
|
|
598
|
+
|
|
599
|
+
# Load base config for space creation using RemoteExecutionContext
|
|
600
|
+
base_config_dict = (
|
|
601
|
+
yaml.safe_load(base_remote_config.read_text()) or {}
|
|
602
|
+
)
|
|
603
|
+
base_remote_context = RemoteExecutionContext.model_validate(
|
|
604
|
+
base_config_dict
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
# Use base config for operation (without wait: true override)
|
|
608
|
+
remote_config_for_operation = base_remote_config
|
|
609
|
+
else:
|
|
610
|
+
# No benchmark packages, use original config
|
|
611
|
+
base_config_dict = (
|
|
612
|
+
yaml.safe_load(self.remote_context_file.read_text()) or {}
|
|
613
|
+
)
|
|
614
|
+
base_remote_context = RemoteExecutionContext.model_validate(
|
|
615
|
+
base_config_dict
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# Use original config for operation
|
|
619
|
+
remote_config_for_operation = self.remote_context_file
|
|
620
|
+
|
|
621
|
+
# Create temporary config for space creation with wait: true
|
|
622
|
+
# Create a new RemoteExecutionContext with wait=True
|
|
623
|
+
space_remote_context = RemoteExecutionContext(
|
|
624
|
+
executionType=base_remote_context.executionType,
|
|
625
|
+
packages=base_remote_context.packages,
|
|
626
|
+
wait=True,
|
|
627
|
+
envVars=base_remote_context.envVars,
|
|
628
|
+
additionalFiles=base_remote_context.additionalFiles,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
with tempfile.NamedTemporaryFile(
|
|
632
|
+
mode="w",
|
|
633
|
+
suffix=".yaml",
|
|
634
|
+
delete=False,
|
|
635
|
+
prefix="remote_config_space_",
|
|
636
|
+
) as tmp_file:
|
|
637
|
+
remote_config_for_space = Path(tmp_file.name)
|
|
638
|
+
tmp_file.write(pydantic_model_as_yaml(space_remote_context))
|
|
639
|
+
tmp_file.flush()
|
|
640
|
+
temp_remote_configs.append(remote_config_for_space)
|
|
641
|
+
|
|
642
|
+
console.print(f" Creating ADO discoveryspace for: {instance_path}")
|
|
643
|
+
space_id, actuator_configuration_ids = self._create_discoveryspace(
|
|
644
|
+
space_yaml_path, instance_path, remote_config_for_space
|
|
645
|
+
)
|
|
646
|
+
result.space_id = space_id
|
|
647
|
+
console.print(f" [green]✓[/green] Successfully created space: {space_id}")
|
|
648
|
+
|
|
649
|
+
console.print(f" Creating operation for space: {space_id}")
|
|
650
|
+
operation_result = self._create_operation(
|
|
651
|
+
space_id,
|
|
652
|
+
instance_path,
|
|
653
|
+
remote_config_for_operation,
|
|
654
|
+
actuator_configuration_ids,
|
|
655
|
+
)
|
|
656
|
+
result.operation_id = operation_result["operation_id"]
|
|
657
|
+
result.ray_job_id = operation_result.get("ray_job_id")
|
|
658
|
+
|
|
659
|
+
# Set status to "started" if Ray job was successfully started, otherwise "success"
|
|
660
|
+
if result.ray_job_id:
|
|
661
|
+
result.status = "started"
|
|
662
|
+
message_parts = [
|
|
663
|
+
f"Successfully started on Ray cluster with job ID: {result.ray_job_id}"
|
|
664
|
+
]
|
|
665
|
+
if result.operation_id:
|
|
666
|
+
message_parts.append(f"Operation ID: {result.operation_id}")
|
|
667
|
+
message_parts.append(f"Space ID: {space_id}")
|
|
668
|
+
else:
|
|
669
|
+
result.status = "success"
|
|
670
|
+
message_parts = [
|
|
671
|
+
f"Successfully created space {space_id} and operation {operation_result['operation_id']}"
|
|
672
|
+
]
|
|
673
|
+
result.message = strip_ansi_codes(" | ".join(message_parts))
|
|
674
|
+
|
|
675
|
+
console.print(
|
|
676
|
+
f" [green]✓[/green] Successfully created operation: {operation_result['operation_id'] or 'pending'}"
|
|
677
|
+
)
|
|
678
|
+
if result.ray_job_id:
|
|
679
|
+
console.print(f" [green]✓[/green] Ray job ID: {result.ray_job_id}")
|
|
680
|
+
|
|
681
|
+
except FileNotFoundError as e:
|
|
682
|
+
result.status = "failed"
|
|
683
|
+
result.message = f"File not found: {e}"
|
|
684
|
+
console_err.print(f" [red]✗[/red] Failed: {result.message}")
|
|
685
|
+
|
|
686
|
+
except subprocess.CalledProcessError as e:
|
|
687
|
+
result.status = "failed"
|
|
688
|
+
result.message = f"ADO CLI error: {strip_ansi_codes(e.stderr or str(e))}"
|
|
689
|
+
console_err.print(f" [red]✗[/red] Failed: {result.message}")
|
|
690
|
+
|
|
691
|
+
except Exception as e:
|
|
692
|
+
result.status = "failed"
|
|
693
|
+
result.message = f"Execution error: {strip_ansi_codes(str(e))}"
|
|
694
|
+
console_err.print(f" [red]✗[/red] Failed: {result.message}")
|
|
695
|
+
|
|
696
|
+
finally:
|
|
697
|
+
# Clean up temporary remote config files
|
|
698
|
+
for temp_config in temp_remote_configs:
|
|
699
|
+
temp_config.unlink(missing_ok=True)
|
|
700
|
+
|
|
701
|
+
return result
|
|
702
|
+
|
|
703
|
+
def _create_discoveryspace(
|
|
704
|
+
self,
|
|
705
|
+
space_yaml_path: Path,
|
|
706
|
+
instance_path: Path,
|
|
707
|
+
remote_config: Path | None = None,
|
|
708
|
+
) -> tuple[str, list[str]]:
|
|
709
|
+
"""Create a discoveryspace using ado CLI.
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
space_yaml_path: Path to space.yaml file
|
|
713
|
+
instance_path: Path to benchmark instance directory
|
|
714
|
+
remote_config: Optional path to remote configuration file (with wait: true for space creation)
|
|
715
|
+
|
|
716
|
+
Returns:
|
|
717
|
+
Tuple of (space identifier, resolved actuator configuration IDs)
|
|
718
|
+
"""
|
|
719
|
+
|
|
720
|
+
# Load the space configuration from YAML
|
|
721
|
+
space_config_dict = yaml.safe_load(space_yaml_path.read_text())
|
|
722
|
+
|
|
723
|
+
# Resolve actuator configuration IDs while the space dict is available
|
|
724
|
+
actuator_configuration_ids: list[str] = []
|
|
725
|
+
if self.actuator_configuration_id_map:
|
|
726
|
+
for experiment in (space_config_dict or {}).get("experiments", []):
|
|
727
|
+
actuator_identifier = experiment.get("actuatorIdentifier")
|
|
728
|
+
if (
|
|
729
|
+
actuator_identifier
|
|
730
|
+
and actuator_identifier in self.actuator_configuration_id_map
|
|
731
|
+
):
|
|
732
|
+
actuator_configuration_ids.append(
|
|
733
|
+
self.actuator_configuration_id_map[actuator_identifier]
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
# Parse the configuration using DiscoverySpaceConfiguration
|
|
737
|
+
space_config = DiscoverySpaceConfiguration.model_validate(space_config_dict)
|
|
738
|
+
|
|
739
|
+
# Generate descriptive name and description from instance path
|
|
740
|
+
# Extract PR number from URL (pr_url may be None in non-PR mode)
|
|
741
|
+
pr_number = self.pr_url.rstrip("/").split("/")[-1] if self.pr_url else "unknown"
|
|
742
|
+
|
|
743
|
+
# Parse instance path to get package, model, and instance names
|
|
744
|
+
package_name, model_name, instance_name = self._parse_instance_path(
|
|
745
|
+
instance_path
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
# Create descriptive name: space-pr123-package-model-instance
|
|
749
|
+
space_name = f"space-pr{pr_number}-{package_name}-{model_name}-{instance_name}"
|
|
750
|
+
space_description = f"Discovery space for benchmark instance from PR #{pr_number}: {package_name}/{model_name}/{instance_name}"
|
|
751
|
+
|
|
752
|
+
# Build custom labels with algorithm-nexus fields
|
|
753
|
+
labels = space_config.metadata.labels or {}
|
|
754
|
+
labels["algorithm-nexus.pr_url"] = self.pr_url
|
|
755
|
+
labels["algorithm-nexus.instance_path"] = str(instance_path)
|
|
756
|
+
|
|
757
|
+
# Update metadata with descriptive name, description, and labels
|
|
758
|
+
space_config.metadata = ConfigurationMetadata(
|
|
759
|
+
name=space_name,
|
|
760
|
+
description=space_description,
|
|
761
|
+
labels=labels,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
with tempfile.NamedTemporaryFile(
|
|
765
|
+
mode="w", suffix=".yaml", delete=True
|
|
766
|
+
) as tmp_file:
|
|
767
|
+
tmp_file.write(pydantic_model_as_yaml(space_config))
|
|
768
|
+
tmp_file.flush()
|
|
769
|
+
temp_space_path = tmp_file.name
|
|
770
|
+
|
|
771
|
+
cmd = ["ado"]
|
|
772
|
+
|
|
773
|
+
# Add context file if provided
|
|
774
|
+
if self.context_file:
|
|
775
|
+
cmd.extend(["--context", str(self.context_file)])
|
|
776
|
+
|
|
777
|
+
# Add remote flag if in remote mode
|
|
778
|
+
if remote_config:
|
|
779
|
+
cmd.extend(["--remote", str(remote_config)])
|
|
780
|
+
|
|
781
|
+
cmd.extend(["create", "discoveryspace", "-f", temp_space_path])
|
|
782
|
+
|
|
783
|
+
result = subprocess.run( # noqa: S603
|
|
784
|
+
cmd,
|
|
785
|
+
capture_output=True,
|
|
786
|
+
text=True,
|
|
787
|
+
check=False,
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
if result.returncode != 0:
|
|
791
|
+
error_msg = result.stderr or result.stdout
|
|
792
|
+
raise subprocess.CalledProcessError(
|
|
793
|
+
result.returncode, cmd, output=result.stdout, stderr=error_msg
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
combined_output = result.stdout + "\n" + result.stderr
|
|
797
|
+
match = re.search(r"identifier[:\s]+(\S+)", combined_output)
|
|
798
|
+
if match:
|
|
799
|
+
return strip_ansi_codes(match.group(1)), actuator_configuration_ids
|
|
800
|
+
else:
|
|
801
|
+
output_words = result.stdout.strip().split()
|
|
802
|
+
if output_words:
|
|
803
|
+
return strip_ansi_codes(
|
|
804
|
+
output_words[-1]
|
|
805
|
+
), actuator_configuration_ids
|
|
806
|
+
else:
|
|
807
|
+
raise ValueError(
|
|
808
|
+
f"Could not extract space identifier from ADO output.\n"
|
|
809
|
+
f"Return code: {result.returncode}\n"
|
|
810
|
+
f"Stdout: {result.stdout[:300]}\n"
|
|
811
|
+
f"Stderr: {result.stderr[:300]}"
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
def _create_or_update_remote_config(
|
|
815
|
+
self,
|
|
816
|
+
benchmark_packages: set[str],
|
|
817
|
+
base_config_path: Path | None = None,
|
|
818
|
+
repo_root: Path | None = None,
|
|
819
|
+
) -> Path:
|
|
820
|
+
"""Create or update a remote configuration file with benchmark package dependencies.
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
benchmark_packages: Set of requirement specifiers for benchmark packages
|
|
824
|
+
base_config_path: Optional base remote config to extend
|
|
825
|
+
repo_root: Repository root to resolve relative paths (defaults to self.repo_root)
|
|
826
|
+
|
|
827
|
+
Returns:
|
|
828
|
+
Path to the created/updated remote config file
|
|
829
|
+
"""
|
|
830
|
+
if repo_root is None:
|
|
831
|
+
repo_root = self.repo_root
|
|
832
|
+
|
|
833
|
+
# Load existing remote config (base_config_path existence is validated by CLI)
|
|
834
|
+
if not base_config_path:
|
|
835
|
+
raise ValueError(
|
|
836
|
+
"base_config_path must be provided with a valid RemoteExecutionContext configuration"
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
remote_config_dict = yaml.safe_load(base_config_path.read_text()) or {}
|
|
840
|
+
remote_config = RemoteExecutionContext.model_validate(remote_config_dict)
|
|
841
|
+
|
|
842
|
+
# Get existing packages
|
|
843
|
+
existing_pypi_packages = set(remote_config.packages.fromPyPI)
|
|
844
|
+
existing_source_packages = set(remote_config.packages.fromSource)
|
|
845
|
+
|
|
846
|
+
# Process benchmark packages
|
|
847
|
+
new_pypi_packages = list(existing_pypi_packages)
|
|
848
|
+
new_source_packages = list(existing_source_packages)
|
|
849
|
+
|
|
850
|
+
for pkg in benchmark_packages:
|
|
851
|
+
# Check if package is a GitHub URL
|
|
852
|
+
|
|
853
|
+
if pkg in existing_pypi_packages or pkg in existing_source_packages:
|
|
854
|
+
# The package is already present in the remote config, no need to add it again
|
|
855
|
+
continue
|
|
856
|
+
|
|
857
|
+
# Check if it is a local package path
|
|
858
|
+
if Path(pkg).exists():
|
|
859
|
+
# Check if package is a local path (relative or absolute)
|
|
860
|
+
new_source_packages.append(pkg)
|
|
861
|
+
else:
|
|
862
|
+
new_pypi_packages.append(pkg)
|
|
863
|
+
|
|
864
|
+
# Update packages configuration
|
|
865
|
+
remote_config.packages = PackageConfiguration(
|
|
866
|
+
fromPyPI=new_pypi_packages,
|
|
867
|
+
fromSource=new_source_packages,
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
with tempfile.NamedTemporaryFile(
|
|
871
|
+
mode="w", suffix=".yaml", delete=False, prefix="remote_config_"
|
|
872
|
+
) as tmp_file:
|
|
873
|
+
temp_path = Path(tmp_file.name)
|
|
874
|
+
tmp_file.write(pydantic_model_as_yaml(remote_config))
|
|
875
|
+
tmp_file.flush()
|
|
876
|
+
return temp_path
|
|
877
|
+
|
|
878
|
+
def _create_operation(
|
|
879
|
+
self,
|
|
880
|
+
space_id: str,
|
|
881
|
+
instance_path: Path | None = None,
|
|
882
|
+
remote_config: Path | None = None,
|
|
883
|
+
actuator_configuration_ids: list[str] | None = None,
|
|
884
|
+
) -> dict[str, str | None]:
|
|
885
|
+
"""Create and execute an operation using ado CLI.
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
space_id: Discovery space identifier
|
|
889
|
+
instance_path: Optional path to benchmark instance (for package resolution)
|
|
890
|
+
remote_config: Optional path to remote configuration file
|
|
891
|
+
actuator_configuration_ids: Actuator configuration IDs resolved from space.yaml
|
|
892
|
+
|
|
893
|
+
Returns:
|
|
894
|
+
Dictionary with operation_id and ray_job_id (if remote execution)
|
|
895
|
+
"""
|
|
896
|
+
# Generate descriptive name and description from instance path
|
|
897
|
+
if instance_path:
|
|
898
|
+
# Extract PR number from URL (pr_url may be None in non-PR mode)
|
|
899
|
+
pr_number = (
|
|
900
|
+
self.pr_url.rstrip("/").split("/")[-1] if self.pr_url else "unknown"
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
# Parse instance path to get package, model, and instance names
|
|
904
|
+
package_name, model_name, instance_name = self._parse_instance_path(
|
|
905
|
+
instance_path
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
# Create descriptive name: randomwalk-pr123-package-model-instance
|
|
909
|
+
operation_name = (
|
|
910
|
+
f"randomwalk-pr{pr_number}-{package_name}-{model_name}-{instance_name}"
|
|
911
|
+
)
|
|
912
|
+
operation_description = f"Random walk for benchmark instance from PR #{pr_number}: {package_name}/{model_name}/{instance_name}"
|
|
913
|
+
else:
|
|
914
|
+
operation_name = "randomwalk-all"
|
|
915
|
+
operation_description = "Perform a random walk on all points in a space"
|
|
916
|
+
|
|
917
|
+
# Create custom metadata with algorithm-nexus fields
|
|
918
|
+
custom_metadata = {
|
|
919
|
+
"algorithm-nexus.pr_url": self.pr_url or "",
|
|
920
|
+
"algorithm-nexus.instance_path": str(instance_path)
|
|
921
|
+
if instance_path
|
|
922
|
+
else "",
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
# Create operation config using the factory function
|
|
926
|
+
operation_config = create_random_walk_operation_config(
|
|
927
|
+
space_id=space_id,
|
|
928
|
+
metadata_name=operation_name,
|
|
929
|
+
metadata_description=operation_description,
|
|
930
|
+
custom_metadata=custom_metadata,
|
|
931
|
+
actuator_configuration_ids=actuator_configuration_ids or None,
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
with tempfile.NamedTemporaryFile(
|
|
935
|
+
mode="w", suffix=".yaml", delete=True
|
|
936
|
+
) as tmp_file:
|
|
937
|
+
tmp_file.write(pydantic_model_as_yaml(operation_config))
|
|
938
|
+
tmp_file.flush()
|
|
939
|
+
operation_config_path = tmp_file.name
|
|
940
|
+
|
|
941
|
+
cmd = ["ado"]
|
|
942
|
+
|
|
943
|
+
# Add context file if provided
|
|
944
|
+
if self.context_file:
|
|
945
|
+
cmd.extend(["--context", str(self.context_file)])
|
|
946
|
+
|
|
947
|
+
# The remote config goes right after the ado command (and context if present)
|
|
948
|
+
if remote_config:
|
|
949
|
+
cmd.extend(["--remote", str(remote_config)])
|
|
950
|
+
|
|
951
|
+
cmd.extend(
|
|
952
|
+
[
|
|
953
|
+
"create",
|
|
954
|
+
"operation",
|
|
955
|
+
"-f",
|
|
956
|
+
operation_config_path,
|
|
957
|
+
]
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
result = subprocess.run( # noqa: S603
|
|
961
|
+
cmd,
|
|
962
|
+
capture_output=True,
|
|
963
|
+
text=True,
|
|
964
|
+
check=True,
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
# Extract Ray job ID and operation identifier
|
|
968
|
+
# For remote execution: Ray job ID (raysubmit_*) is available immediately
|
|
969
|
+
# Operation ID is only available after execution completes
|
|
970
|
+
|
|
971
|
+
combined_output = result.stdout + "\n" + result.stderr
|
|
972
|
+
|
|
973
|
+
# Extract Ray job ID (starts with raysubmit_) for remote execution
|
|
974
|
+
ray_job_id = None
|
|
975
|
+
if remote_config:
|
|
976
|
+
ray_match = re.search(r"(raysubmit_\w+)", combined_output)
|
|
977
|
+
if ray_match:
|
|
978
|
+
ray_job_id = strip_ansi_codes(ray_match.group(1))
|
|
979
|
+
|
|
980
|
+
# For local execution or completed remote execution, extract operation ID
|
|
981
|
+
# Operation ID is available after "identifier" keyword
|
|
982
|
+
operation_id = None
|
|
983
|
+
op_match = re.search(r"identifier\s+(\S+)", combined_output)
|
|
984
|
+
if op_match:
|
|
985
|
+
candidate = strip_ansi_codes(op_match.group(1))
|
|
986
|
+
# Only use as operation_id if it's not a raysubmit ID
|
|
987
|
+
if not candidate.startswith("raysubmit_"):
|
|
988
|
+
operation_id = candidate
|
|
989
|
+
|
|
990
|
+
# Fallback for local execution: use last word if no operation_id found
|
|
991
|
+
if not operation_id and not ray_job_id:
|
|
992
|
+
words = result.stdout.strip().split()
|
|
993
|
+
if words:
|
|
994
|
+
operation_id = strip_ansi_codes(words[-1])
|
|
995
|
+
|
|
996
|
+
return {
|
|
997
|
+
"operation_id": operation_id,
|
|
998
|
+
"ray_job_id": ray_job_id,
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
def run(self) -> dict[str, Any]:
|
|
1002
|
+
"""Main execution method.
|
|
1003
|
+
|
|
1004
|
+
Returns:
|
|
1005
|
+
Dictionary with execution results
|
|
1006
|
+
"""
|
|
1007
|
+
try:
|
|
1008
|
+
console.print("Analyzing PR for new or changed benchmark instances...")
|
|
1009
|
+
console.print(f"PR URL: {self.pr_url}")
|
|
1010
|
+
|
|
1011
|
+
# Always check if we need to checkout PR code at the beginning
|
|
1012
|
+
if not self.is_local_repo_on_pr_commit():
|
|
1013
|
+
console.print("[yellow]Local repository is not on PR commit[/yellow]")
|
|
1014
|
+
console.print("Checking out PR code to temporary directory...")
|
|
1015
|
+
self.checkout_pr_to_temp()
|
|
1016
|
+
else:
|
|
1017
|
+
console.print(
|
|
1018
|
+
"[green]✓[/green] Using local repository (already on PR commit)"
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
changed_files = self.get_changed_files()
|
|
1022
|
+
|
|
1023
|
+
benchmark_instances = self.find_benchmark_instances(changed_files)
|
|
1024
|
+
|
|
1025
|
+
if not benchmark_instances:
|
|
1026
|
+
console.print(
|
|
1027
|
+
"[yellow]No new or changed benchmark instances found in this PR.[/yellow]"
|
|
1028
|
+
)
|
|
1029
|
+
return {"instances": []}
|
|
1030
|
+
|
|
1031
|
+
console.print(f"Found {len(benchmark_instances)} benchmark instance(s):")
|
|
1032
|
+
|
|
1033
|
+
results: dict[str, Any] = {
|
|
1034
|
+
"instances": [],
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if self.execute:
|
|
1038
|
+
console.print("\nExecuting benchmarks with ADO CLI...")
|
|
1039
|
+
successful = 0
|
|
1040
|
+
failed = 0
|
|
1041
|
+
|
|
1042
|
+
for instance_path in benchmark_instances:
|
|
1043
|
+
console.print(f"\nProcessing: {instance_path}")
|
|
1044
|
+
exec_result = self.execute_benchmark(instance_path)
|
|
1045
|
+
results["instances"].append(exec_result.model_dump())
|
|
1046
|
+
|
|
1047
|
+
# When execution is local we return "success" when remote we return "started"
|
|
1048
|
+
if exec_result.status in ["started", "success"]:
|
|
1049
|
+
successful += 1
|
|
1050
|
+
else:
|
|
1051
|
+
failed += 1
|
|
1052
|
+
|
|
1053
|
+
else:
|
|
1054
|
+
for instance_path in benchmark_instances:
|
|
1055
|
+
console.print(f" {instance_path}")
|
|
1056
|
+
results["instances"].append({"instance_path": str(instance_path)})
|
|
1057
|
+
|
|
1058
|
+
return results
|
|
1059
|
+
finally:
|
|
1060
|
+
self.cleanup_temp_dir()
|
|
1061
|
+
|
|
1062
|
+
def find_all_benchmark_instances(
|
|
1063
|
+
self, packages_root: Path, package_filter: str | None = None
|
|
1064
|
+
) -> list[Path]:
|
|
1065
|
+
"""Find all benchmark instances in the packages directory.
|
|
1066
|
+
|
|
1067
|
+
Args:
|
|
1068
|
+
packages_root: Path to packages directory
|
|
1069
|
+
package_filter: Optional package name to filter by
|
|
1070
|
+
|
|
1071
|
+
Returns:
|
|
1072
|
+
List of benchmark instance directory paths (relative to repo_root)
|
|
1073
|
+
"""
|
|
1074
|
+
benchmark_instances = []
|
|
1075
|
+
|
|
1076
|
+
# Resolve packages_root to absolute path
|
|
1077
|
+
packages_root_abs = packages_root.resolve()
|
|
1078
|
+
|
|
1079
|
+
if not packages_root_abs.exists():
|
|
1080
|
+
console.print(
|
|
1081
|
+
f"[red]Error:[/red] Packages directory not found: {packages_root_abs}"
|
|
1082
|
+
)
|
|
1083
|
+
raise typer.Exit(code=1)
|
|
1084
|
+
|
|
1085
|
+
# Only update repo_root after confirming the path exists
|
|
1086
|
+
self.repo_root = packages_root_abs.parent # Parent of packages directory
|
|
1087
|
+
|
|
1088
|
+
# Walk through packages directory
|
|
1089
|
+
for pkg_dir in packages_root_abs.iterdir():
|
|
1090
|
+
if not pkg_dir.is_dir() or pkg_dir.name.startswith("."):
|
|
1091
|
+
continue
|
|
1092
|
+
|
|
1093
|
+
# Filter by package name if specified
|
|
1094
|
+
if package_filter and pkg_dir.name != package_filter:
|
|
1095
|
+
continue
|
|
1096
|
+
|
|
1097
|
+
# Find benchmark_instances directories
|
|
1098
|
+
# Check package-level benchmark_instances
|
|
1099
|
+
pkg_benchmark_dir = pkg_dir / "benchmark_instances"
|
|
1100
|
+
if pkg_benchmark_dir.exists() and pkg_benchmark_dir.is_dir():
|
|
1101
|
+
benchmark_instances.extend(
|
|
1102
|
+
instance_dir.relative_to(self.repo_root)
|
|
1103
|
+
for instance_dir in pkg_benchmark_dir.iterdir()
|
|
1104
|
+
if instance_dir.is_dir() and (instance_dir / "space.yaml").exists()
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
# Check model-level benchmark_instances
|
|
1108
|
+
models_dir = pkg_dir / "models"
|
|
1109
|
+
if models_dir.exists() and models_dir.is_dir():
|
|
1110
|
+
for model_dir in models_dir.iterdir():
|
|
1111
|
+
if not model_dir.is_dir():
|
|
1112
|
+
continue
|
|
1113
|
+
model_benchmark_dir = model_dir / "benchmark_instances"
|
|
1114
|
+
if model_benchmark_dir.exists() and model_benchmark_dir.is_dir():
|
|
1115
|
+
benchmark_instances.extend(
|
|
1116
|
+
instance_dir.relative_to(self.repo_root)
|
|
1117
|
+
for instance_dir in model_benchmark_dir.iterdir()
|
|
1118
|
+
if instance_dir.is_dir()
|
|
1119
|
+
and (instance_dir / "space.yaml").exists()
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
return sorted(benchmark_instances)
|
|
1123
|
+
|
|
1124
|
+
def validate(
|
|
1125
|
+
self,
|
|
1126
|
+
packages_root: Path | None = None,
|
|
1127
|
+
package_filter: str | None = None,
|
|
1128
|
+
verbose: bool = False,
|
|
1129
|
+
fail_fast: bool = False,
|
|
1130
|
+
) -> ValidationReport:
|
|
1131
|
+
"""Validate benchmark instances with ADO dry-run in isolated venvs.
|
|
1132
|
+
|
|
1133
|
+
Args:
|
|
1134
|
+
packages_root: Path to packages directory (for all/package mode)
|
|
1135
|
+
package_filter: Optional package name to filter by
|
|
1136
|
+
verbose: Show detailed validation output
|
|
1137
|
+
fail_fast: Stop validation on first error
|
|
1138
|
+
|
|
1139
|
+
Returns:
|
|
1140
|
+
Dictionary with validation results
|
|
1141
|
+
"""
|
|
1142
|
+
from algorithm_nexus.commands.ado_validator import validate_with_ado
|
|
1143
|
+
from algorithm_nexus.commands.venv_manager import (
|
|
1144
|
+
cleanup_venv,
|
|
1145
|
+
create_temp_venv,
|
|
1146
|
+
install_packages,
|
|
1147
|
+
)
|
|
1148
|
+
from algorithm_nexus.models import ValidationResult
|
|
1149
|
+
|
|
1150
|
+
try:
|
|
1151
|
+
# Print mode header
|
|
1152
|
+
self._print_mode_header(packages_root, package_filter, "Validating")
|
|
1153
|
+
|
|
1154
|
+
# Discover benchmark instances
|
|
1155
|
+
benchmark_instances = self._discover_instances(
|
|
1156
|
+
packages_root, package_filter
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
# Print found instances
|
|
1160
|
+
self._print_instances_found(benchmark_instances, package_filter)
|
|
1161
|
+
|
|
1162
|
+
if not benchmark_instances:
|
|
1163
|
+
return ValidationReport(instances=[], successful=0, failed=0, total=0)
|
|
1164
|
+
|
|
1165
|
+
# Resolve dependencies for all instances
|
|
1166
|
+
instance_dependencies = self._resolve_all_dependencies(
|
|
1167
|
+
benchmark_instances, verbose
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
# Validate each instance with its own venv
|
|
1171
|
+
console.print("\n[bold]Validating benchmark instances...[/bold]")
|
|
1172
|
+
console.print("=" * 60)
|
|
1173
|
+
|
|
1174
|
+
all_results = []
|
|
1175
|
+
total_success = 0
|
|
1176
|
+
total_failed = 0
|
|
1177
|
+
|
|
1178
|
+
for instance in benchmark_instances:
|
|
1179
|
+
resolved_req_list = instance_dependencies[instance]
|
|
1180
|
+
|
|
1181
|
+
console.print(f"\n[cyan]Validating:[/cyan] {instance}")
|
|
1182
|
+
if resolved_req_list:
|
|
1183
|
+
console.print(
|
|
1184
|
+
f" [cyan]Dependencies:[/cyan] {', '.join(resolved_req_list)}"
|
|
1185
|
+
)
|
|
1186
|
+
else:
|
|
1187
|
+
console.print(" [cyan]No dependencies required[/cyan]")
|
|
1188
|
+
|
|
1189
|
+
# Create venv for this instance
|
|
1190
|
+
venv_path = None
|
|
1191
|
+
try:
|
|
1192
|
+
venv_path = create_temp_venv()
|
|
1193
|
+
|
|
1194
|
+
# Install benchmark packages if needed
|
|
1195
|
+
if resolved_req_list:
|
|
1196
|
+
success = install_packages(
|
|
1197
|
+
venv_path, resolved_req_list, verbose=verbose
|
|
1198
|
+
)
|
|
1199
|
+
if not success:
|
|
1200
|
+
console.print(
|
|
1201
|
+
"[red]✗[/red] Failed to install packages, skipping validation"
|
|
1202
|
+
)
|
|
1203
|
+
# Create ValidationResult for failed dependency installation
|
|
1204
|
+
failed_result = ValidationResult(
|
|
1205
|
+
success=False,
|
|
1206
|
+
instance_path=str(instance),
|
|
1207
|
+
errors=["Failed to install dependencies"],
|
|
1208
|
+
warnings=[],
|
|
1209
|
+
)
|
|
1210
|
+
all_results.append(failed_result.model_dump())
|
|
1211
|
+
total_failed += 1
|
|
1212
|
+
if fail_fast:
|
|
1213
|
+
break
|
|
1214
|
+
continue
|
|
1215
|
+
|
|
1216
|
+
# Validate this instance
|
|
1217
|
+
console.print(" Running validation...")
|
|
1218
|
+
|
|
1219
|
+
result = validate_with_ado(
|
|
1220
|
+
base_path=self.repo_root,
|
|
1221
|
+
instance_path=str(instance),
|
|
1222
|
+
venv_path=venv_path,
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
# Convert ValidationResult to summary dict
|
|
1226
|
+
all_results.append(result.model_dump())
|
|
1227
|
+
|
|
1228
|
+
if result.success:
|
|
1229
|
+
console.print(" [green]✓[/green] Validation passed")
|
|
1230
|
+
total_success += 1
|
|
1231
|
+
else:
|
|
1232
|
+
console.print(" [red]✗[/red] Validation failed")
|
|
1233
|
+
total_failed += 1
|
|
1234
|
+
|
|
1235
|
+
if not result.success:
|
|
1236
|
+
for error in result.errors:
|
|
1237
|
+
console.print(f" [red]Error:[/red] {error}")
|
|
1238
|
+
|
|
1239
|
+
for warning in result.warnings:
|
|
1240
|
+
console.print(f" [yellow]Warning:[/yellow] {warning}")
|
|
1241
|
+
|
|
1242
|
+
finally:
|
|
1243
|
+
if venv_path:
|
|
1244
|
+
cleanup_venv(venv_path)
|
|
1245
|
+
|
|
1246
|
+
if fail_fast and total_failed > 0:
|
|
1247
|
+
console.print(
|
|
1248
|
+
"\n[yellow]Stopping validation (--fail-fast)[/yellow]"
|
|
1249
|
+
)
|
|
1250
|
+
break
|
|
1251
|
+
|
|
1252
|
+
# Return results
|
|
1253
|
+
return ValidationReport(
|
|
1254
|
+
instances=all_results,
|
|
1255
|
+
successful=total_success,
|
|
1256
|
+
failed=total_failed,
|
|
1257
|
+
total=len(benchmark_instances),
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
finally:
|
|
1261
|
+
self.cleanup_temp_dir()
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
# Made with Bob
|