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.
@@ -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