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,683 @@
|
|
|
1
|
+
# Copyright IBM Corp. 2026
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Validate command for Algorithm Nexus CLI."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Any, Literal
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import typer
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
except ImportError:
|
|
18
|
+
print(
|
|
19
|
+
"Error: CLI dependencies are not installed.\n"
|
|
20
|
+
"Please install them with: pip install algorithm-nexus[cli]",
|
|
21
|
+
file=sys.stderr,
|
|
22
|
+
)
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
from algorithm_nexus.commands.utils import (
|
|
26
|
+
ValidationErrorCollector,
|
|
27
|
+
determine_output_format,
|
|
28
|
+
load_yaml_file,
|
|
29
|
+
print_results_table,
|
|
30
|
+
print_structured_results,
|
|
31
|
+
validate_output_format,
|
|
32
|
+
write_results_to_file,
|
|
33
|
+
)
|
|
34
|
+
from algorithm_nexus.models import (
|
|
35
|
+
AlgorithmNexusModelConfig,
|
|
36
|
+
AlgorithmNexusPackageConfig,
|
|
37
|
+
BenchmarkBinding,
|
|
38
|
+
CategoricalValueMapping,
|
|
39
|
+
FieldMapping,
|
|
40
|
+
LogicalBenchmarkConfig,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
console = Console()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_pydantic_error(error: dict[str, Any], file_path: Path) -> str:
|
|
47
|
+
"""Format a Pydantic validation error into a readable message."""
|
|
48
|
+
loc = ".".join(str(x) for x in error["loc"])
|
|
49
|
+
msg = error["msg"]
|
|
50
|
+
|
|
51
|
+
# Extract more context from the error if available
|
|
52
|
+
error_type = error.get("type", "")
|
|
53
|
+
|
|
54
|
+
# For value_error types, the message usually contains the full explanation
|
|
55
|
+
if error_type.startswith("value_error"):
|
|
56
|
+
# The message already contains the full context
|
|
57
|
+
return f"[bold]{file_path}[/bold]\n Field: [cyan]{loc}[/cyan]\n Error: {msg}"
|
|
58
|
+
|
|
59
|
+
# For missing field errors
|
|
60
|
+
if error_type == "missing":
|
|
61
|
+
return f"[bold]{file_path}[/bold]\n Field: [cyan]{loc}[/cyan]\n Error: This required field is missing"
|
|
62
|
+
|
|
63
|
+
# Default format for other errors
|
|
64
|
+
return f"[bold]{file_path}[/bold]\n Field: [cyan]{loc}[/cyan]\n Error: {msg}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_nexus_yaml(
|
|
68
|
+
package_dir: Path,
|
|
69
|
+
collector: ValidationErrorCollector,
|
|
70
|
+
) -> AlgorithmNexusPackageConfig | None:
|
|
71
|
+
"""Validate nexus.yaml.
|
|
72
|
+
|
|
73
|
+
Returns the validated package config if successful, None otherwise.
|
|
74
|
+
"""
|
|
75
|
+
nexus_yaml_path = package_dir / "nexus.yaml"
|
|
76
|
+
data = load_yaml_file(nexus_yaml_path, collector)
|
|
77
|
+
if data is None:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
return AlgorithmNexusPackageConfig.model_validate(data)
|
|
82
|
+
except ValidationError as exc:
|
|
83
|
+
for error in exc.errors():
|
|
84
|
+
collector.add(format_pydantic_error(error, nexus_yaml_path))
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_model_yaml(
|
|
89
|
+
model_dir: Path, collector: ValidationErrorCollector
|
|
90
|
+
) -> AlgorithmNexusModelConfig | None:
|
|
91
|
+
"""Validate a model's model.yaml file.
|
|
92
|
+
|
|
93
|
+
Returns the validated model config if successful, None otherwise.
|
|
94
|
+
"""
|
|
95
|
+
model_yaml_path = model_dir / "model.yaml"
|
|
96
|
+
data = load_yaml_file(model_yaml_path, collector)
|
|
97
|
+
if data is None:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
return AlgorithmNexusModelConfig.model_validate(data)
|
|
102
|
+
except ValidationError as exc:
|
|
103
|
+
for error in exc.errors():
|
|
104
|
+
collector.add(format_pydantic_error(error, model_yaml_path))
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate_benchmark_instances(
|
|
109
|
+
model_dir: Path,
|
|
110
|
+
collector: ValidationErrorCollector,
|
|
111
|
+
registered_experiments: set[str],
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Validate a model's benchmark_instances/ folder.
|
|
114
|
+
|
|
115
|
+
Validates that each benchmark instance has a space.yaml file.
|
|
116
|
+
Does not validate the contents of space.yaml as that is ADO's responsibility.
|
|
117
|
+
"""
|
|
118
|
+
benchmark_instances_dir = model_dir / "benchmark_instances"
|
|
119
|
+
|
|
120
|
+
# benchmark_instances/ is optional
|
|
121
|
+
if not benchmark_instances_dir.exists():
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
if not benchmark_instances_dir.is_dir():
|
|
125
|
+
collector.add(
|
|
126
|
+
f"benchmark_instances must be a directory when present: {benchmark_instances_dir}"
|
|
127
|
+
)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
# Validate each benchmark instance folder
|
|
131
|
+
for instance_dir in benchmark_instances_dir.iterdir():
|
|
132
|
+
if not instance_dir.is_dir():
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
space_yaml_path = instance_dir / "space.yaml"
|
|
136
|
+
|
|
137
|
+
# Each benchmark instance must have a space.yaml
|
|
138
|
+
if not space_yaml_path.exists():
|
|
139
|
+
collector.add(
|
|
140
|
+
f"[bold]{instance_dir}[/bold]\n"
|
|
141
|
+
f" Error: Missing required space.yaml file for benchmark instance '{instance_dir.name}'"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def validate_optional_file(
|
|
146
|
+
path: Path, collector: ValidationErrorCollector, context: str
|
|
147
|
+
) -> bool:
|
|
148
|
+
"""Validate optional file and add info if missing. Returns True if valid or doesn't exist."""
|
|
149
|
+
if not path.exists():
|
|
150
|
+
collector.add_info(f"{context}")
|
|
151
|
+
return False
|
|
152
|
+
elif not path.is_file():
|
|
153
|
+
collector.add(f"{context} must be a file when present: {path}")
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def validate_optional_dir(
|
|
160
|
+
path: Path, collector: ValidationErrorCollector, context: str
|
|
161
|
+
) -> bool:
|
|
162
|
+
"""Validate optional directory and add info if missing. Returns True if valid or doesn't exist."""
|
|
163
|
+
if not path.exists():
|
|
164
|
+
collector.add_info(f"{context}")
|
|
165
|
+
return False
|
|
166
|
+
elif not path.is_dir():
|
|
167
|
+
collector.add(f"{context} must be a directory when present: {path}")
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def validate_model_directory(
|
|
174
|
+
model_dir: Path,
|
|
175
|
+
collector: ValidationErrorCollector,
|
|
176
|
+
registered_experiments: set[str],
|
|
177
|
+
) -> AlgorithmNexusModelConfig | None:
|
|
178
|
+
"""Validate a single model directory structure and contents.
|
|
179
|
+
|
|
180
|
+
Returns the validated model config if successful, None otherwise.
|
|
181
|
+
"""
|
|
182
|
+
if not model_dir.is_dir():
|
|
183
|
+
collector.add(f"Model path is not a directory: {model_dir}")
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
# Validate optional usage.md
|
|
187
|
+
usage_md = model_dir / "usage.md"
|
|
188
|
+
validate_optional_file(
|
|
189
|
+
usage_md,
|
|
190
|
+
collector,
|
|
191
|
+
f"Optional model file missing for '{model_dir.name}': usage.md",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Validate model.yaml
|
|
195
|
+
model_config = validate_model_yaml(model_dir, collector)
|
|
196
|
+
|
|
197
|
+
# Validate optional benchmark_instances/
|
|
198
|
+
validate_benchmark_instances(model_dir, collector, registered_experiments)
|
|
199
|
+
|
|
200
|
+
return model_config
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def validate_package_directory(
|
|
204
|
+
package_dir: Path, collector: ValidationErrorCollector
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Validate the structure and contents of a Nexus package directory."""
|
|
207
|
+
if not package_dir.is_dir():
|
|
208
|
+
collector.add(f"Package path is not a directory: {package_dir}")
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
# Validate nexus.yaml and extract registered experiments
|
|
212
|
+
package_config = validate_nexus_yaml(package_dir, collector)
|
|
213
|
+
registered_experiments: set[str] = set()
|
|
214
|
+
if package_config and package_config.package.benchmark_packages:
|
|
215
|
+
# Collect all experiment identifiers from all benchmark packages
|
|
216
|
+
for pkg in package_config.package.benchmark_packages:
|
|
217
|
+
registered_experiments.update(pkg.experiments)
|
|
218
|
+
|
|
219
|
+
# Validate unique experiment identifiers across all packages
|
|
220
|
+
all_experiments = []
|
|
221
|
+
for pkg in package_config.package.benchmark_packages:
|
|
222
|
+
all_experiments.extend(pkg.experiments)
|
|
223
|
+
duplicates = {exp for exp in all_experiments if all_experiments.count(exp) > 1}
|
|
224
|
+
if duplicates:
|
|
225
|
+
collector.add(
|
|
226
|
+
f"Duplicate experiment identifiers across benchmark packages in nexus.yaml: {', '.join(sorted(duplicates))}"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Validate optional skills directory
|
|
230
|
+
skills_dir = package_dir / "skills"
|
|
231
|
+
validate_optional_dir(
|
|
232
|
+
skills_dir, collector, "Optional package directory missing: skills"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Validate optional benchmark_packages directory
|
|
236
|
+
benchmark_packages_dir = package_dir / "benchmark_packages"
|
|
237
|
+
if validate_optional_dir(
|
|
238
|
+
benchmark_packages_dir,
|
|
239
|
+
collector,
|
|
240
|
+
"Optional package directory missing: benchmark_packages",
|
|
241
|
+
):
|
|
242
|
+
# Check that each subdirectory is a valid Python package
|
|
243
|
+
for pkg_dir in benchmark_packages_dir.iterdir():
|
|
244
|
+
if not pkg_dir.is_dir():
|
|
245
|
+
continue
|
|
246
|
+
pyproject_toml = pkg_dir / "pyproject.toml"
|
|
247
|
+
if not pyproject_toml.exists():
|
|
248
|
+
collector.add_info(
|
|
249
|
+
f"Optional file missing in benchmark_packages/{pkg_dir.name}/: pyproject.toml (required for local benchmark Python package)"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Validate optional package-level benchmark_instances directory
|
|
253
|
+
# The validate_benchmark_instances function expects a directory that contains benchmark_instances/
|
|
254
|
+
# So we pass package_dir and it will look for package_dir/benchmark_instances/
|
|
255
|
+
validate_benchmark_instances(package_dir, collector, registered_experiments)
|
|
256
|
+
|
|
257
|
+
# Check if models directory exists
|
|
258
|
+
models_dir = package_dir / "models"
|
|
259
|
+
if not validate_optional_dir(
|
|
260
|
+
models_dir, collector, "Optional package directory missing: models"
|
|
261
|
+
):
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
# Track HuggingFace model IDs to detect duplicates
|
|
265
|
+
hf_id_to_models: dict[str, list[str]] = {}
|
|
266
|
+
|
|
267
|
+
# Only validate model directories if models_dir exists
|
|
268
|
+
for model_dir in models_dir.iterdir():
|
|
269
|
+
model_config = validate_model_directory(
|
|
270
|
+
model_dir, collector, registered_experiments
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Extract HF ID from validated model config for duplicate checking
|
|
274
|
+
if model_config is not None:
|
|
275
|
+
hf_id = model_config.model.id
|
|
276
|
+
if hf_id not in hf_id_to_models:
|
|
277
|
+
hf_id_to_models[hf_id] = []
|
|
278
|
+
hf_id_to_models[hf_id].append(model_dir.name)
|
|
279
|
+
|
|
280
|
+
# Check for duplicate HuggingFace model IDs
|
|
281
|
+
for hf_id, model_names in hf_id_to_models.items():
|
|
282
|
+
if len(model_names) > 1:
|
|
283
|
+
models_list = ", ".join(f"'{name}'" for name in sorted(model_names))
|
|
284
|
+
collector.add(
|
|
285
|
+
f"Duplicate HuggingFace model ID '{hf_id}' found in models: {models_list}"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def validate_package(
|
|
290
|
+
package_path: Annotated[
|
|
291
|
+
Path,
|
|
292
|
+
typer.Argument(
|
|
293
|
+
help="Path to a Nexus package directory.",
|
|
294
|
+
dir_okay=True,
|
|
295
|
+
file_okay=False,
|
|
296
|
+
readable=True,
|
|
297
|
+
resolve_path=True,
|
|
298
|
+
),
|
|
299
|
+
],
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Validate Nexus package structure and YAML configuration files."""
|
|
302
|
+
collector = ValidationErrorCollector()
|
|
303
|
+
validate_package_directory(package_path, collector)
|
|
304
|
+
|
|
305
|
+
if collector.has_errors:
|
|
306
|
+
console.print(
|
|
307
|
+
Panel(
|
|
308
|
+
collector.format_errors(),
|
|
309
|
+
title="[bold red]Validation Failed[/bold red]",
|
|
310
|
+
border_style="red",
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
raise typer.Exit(code=1)
|
|
314
|
+
|
|
315
|
+
# Build success message
|
|
316
|
+
success_message = "[green]✓[/green] All validation checks passed"
|
|
317
|
+
|
|
318
|
+
if collector.has_info:
|
|
319
|
+
success_message += (
|
|
320
|
+
"\n\n[bold]Optional files/directories:[/bold]\n" + collector.format_info()
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
console.print(
|
|
324
|
+
Panel(
|
|
325
|
+
success_message,
|
|
326
|
+
title="[bold green]Validation Successful[/bold green]",
|
|
327
|
+
border_style="green",
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _print_validation_table(all_results: list[dict[str, Any]]) -> None:
|
|
333
|
+
rows = []
|
|
334
|
+
for result in all_results:
|
|
335
|
+
issues = []
|
|
336
|
+
if result.get("errors"):
|
|
337
|
+
issues.extend([f"E: {e}" for e in result["errors"]])
|
|
338
|
+
if result.get("warnings"):
|
|
339
|
+
issues.extend([f"W: {w}" for w in result["warnings"]])
|
|
340
|
+
rows.append(
|
|
341
|
+
{
|
|
342
|
+
"instance_path": result["instance_path"],
|
|
343
|
+
"status": result["status"],
|
|
344
|
+
"details": "\n".join(issues) if issues else "-",
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
print_results_table(rows, title="Validation Results", details_column="Issues")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def validate_benchmarks(
|
|
351
|
+
pr_url: Annotated[
|
|
352
|
+
str | None,
|
|
353
|
+
typer.Option(
|
|
354
|
+
"--pr",
|
|
355
|
+
help="GitHub Pull Request URL (e.g., https://github.com/IBM/algorithm-nexus/pull/123). "
|
|
356
|
+
"If not provided, validates all benchmark instances.",
|
|
357
|
+
),
|
|
358
|
+
] = None,
|
|
359
|
+
packages_root: Annotated[
|
|
360
|
+
Path,
|
|
361
|
+
typer.Option(
|
|
362
|
+
"--packages-root",
|
|
363
|
+
help="Path to packages directory",
|
|
364
|
+
),
|
|
365
|
+
] = Path("./packages"),
|
|
366
|
+
package: Annotated[
|
|
367
|
+
str | None,
|
|
368
|
+
typer.Option(
|
|
369
|
+
"--package",
|
|
370
|
+
help="Validate only benchmark instances from a specific package",
|
|
371
|
+
),
|
|
372
|
+
] = None,
|
|
373
|
+
verbose: Annotated[
|
|
374
|
+
bool,
|
|
375
|
+
typer.Option(
|
|
376
|
+
"--verbose",
|
|
377
|
+
help="Show detailed validation output",
|
|
378
|
+
),
|
|
379
|
+
] = False,
|
|
380
|
+
fail_fast: Annotated[
|
|
381
|
+
bool,
|
|
382
|
+
typer.Option(
|
|
383
|
+
"--fail-fast",
|
|
384
|
+
help="Stop validation on first error",
|
|
385
|
+
),
|
|
386
|
+
] = False,
|
|
387
|
+
output_format: Annotated[
|
|
388
|
+
Literal["json", "yaml", "table"] | None,
|
|
389
|
+
typer.Option(
|
|
390
|
+
"-o",
|
|
391
|
+
"--output-format",
|
|
392
|
+
help="Output format: 'json', 'yaml', or 'table' (default: table)",
|
|
393
|
+
),
|
|
394
|
+
] = None,
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Validate benchmark instances.
|
|
397
|
+
|
|
398
|
+
This command supports three modes:
|
|
399
|
+
1. PR mode: Validate instances modified in a PR (provide pr_url)
|
|
400
|
+
2. All mode: Validate all benchmark instances (no pr_url)
|
|
401
|
+
3. Package mode: Validate instances from a specific package (use --package)
|
|
402
|
+
|
|
403
|
+
The command:
|
|
404
|
+
- Installs required benchmark packages in isolated virtual environments
|
|
405
|
+
- Validates each instance with ADO dry-run
|
|
406
|
+
- Reports validation results
|
|
407
|
+
"""
|
|
408
|
+
from algorithm_nexus.commands.benchmark_manager import BenchmarkManager
|
|
409
|
+
|
|
410
|
+
# Validate output format if specified
|
|
411
|
+
if output_format:
|
|
412
|
+
validate_output_format(
|
|
413
|
+
output_format, allow_yaml=True, allow_csv=False, allow_table=True
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Warn if both package filter and PR URL are provided (package is ignored in PR mode)
|
|
417
|
+
if package and pr_url:
|
|
418
|
+
console.print(
|
|
419
|
+
"[yellow]Warning:[/yellow] --package is ignored when --pr is specified. "
|
|
420
|
+
"In PR mode, only instances changed in the PR are validated."
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# Validate package exists if package filter is specified (non-PR mode only)
|
|
424
|
+
if package and not pr_url:
|
|
425
|
+
package_path = packages_root / package
|
|
426
|
+
if not package_path.is_dir():
|
|
427
|
+
console.print(
|
|
428
|
+
f"[red]Error:[/red] Package '{package}' not found in {packages_root.resolve()}"
|
|
429
|
+
)
|
|
430
|
+
console.print(
|
|
431
|
+
"\nTo see available packages, run: [cyan]nexus list packages[/cyan]"
|
|
432
|
+
)
|
|
433
|
+
raise typer.Exit(code=1)
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
# Create BenchmarkManager based on mode
|
|
437
|
+
if pr_url:
|
|
438
|
+
# PR mode
|
|
439
|
+
manager = BenchmarkManager(pr_url=pr_url, execute=False)
|
|
440
|
+
results = manager.validate(
|
|
441
|
+
packages_root=None,
|
|
442
|
+
package_filter=None,
|
|
443
|
+
verbose=verbose,
|
|
444
|
+
fail_fast=fail_fast,
|
|
445
|
+
)
|
|
446
|
+
else:
|
|
447
|
+
# All or package mode
|
|
448
|
+
manager = BenchmarkManager(pr_url=None, execute=False)
|
|
449
|
+
results = manager.validate(
|
|
450
|
+
packages_root=packages_root,
|
|
451
|
+
package_filter=package,
|
|
452
|
+
verbose=verbose,
|
|
453
|
+
fail_fast=fail_fast,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# Extract results
|
|
457
|
+
all_results = results.instances
|
|
458
|
+
total_failed = results.failed
|
|
459
|
+
|
|
460
|
+
# Determine output format (default to table for human-readable)
|
|
461
|
+
fmt = output_format or "table"
|
|
462
|
+
|
|
463
|
+
# Format output based on requested format
|
|
464
|
+
if fmt in ("json", "yaml"):
|
|
465
|
+
# Structured output (JSON or YAML)
|
|
466
|
+
output_data = {
|
|
467
|
+
"pr_url": pr_url,
|
|
468
|
+
"instances": all_results,
|
|
469
|
+
}
|
|
470
|
+
print_structured_results(output_data, fmt)
|
|
471
|
+
else:
|
|
472
|
+
# Human-readable table output (default)
|
|
473
|
+
_print_validation_table(all_results)
|
|
474
|
+
|
|
475
|
+
# Exit with error if any validation failed
|
|
476
|
+
if total_failed > 0:
|
|
477
|
+
raise typer.Exit(code=1)
|
|
478
|
+
|
|
479
|
+
except KeyboardInterrupt:
|
|
480
|
+
console.print("\n[yellow]Interrupted by user[/yellow]")
|
|
481
|
+
raise typer.Exit(code=130)
|
|
482
|
+
except typer.Exit:
|
|
483
|
+
# Re-raise typer.Exit to preserve exit codes
|
|
484
|
+
raise
|
|
485
|
+
except Exception as e:
|
|
486
|
+
console.print(f"\n[red]Error:[/red] {e}")
|
|
487
|
+
if verbose:
|
|
488
|
+
import traceback
|
|
489
|
+
|
|
490
|
+
console.print(traceback.format_exc())
|
|
491
|
+
raise typer.Exit(code=1)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _check_binding_integrity(
|
|
495
|
+
binding: BenchmarkBinding,
|
|
496
|
+
definition_id: str,
|
|
497
|
+
property_ids: set[str],
|
|
498
|
+
metric_ids: set[str] | None,
|
|
499
|
+
collector: ValidationErrorCollector,
|
|
500
|
+
file_path: Path,
|
|
501
|
+
binding_index: int,
|
|
502
|
+
) -> None:
|
|
503
|
+
"""Check referential integrity between a binding and its parent definition."""
|
|
504
|
+
prefix = f"[bold]{file_path}[/bold]\n Binding[{binding_index}]"
|
|
505
|
+
|
|
506
|
+
# 1. benchmarkIdentifier must match the definition
|
|
507
|
+
if binding.benchmarkIdentifier != definition_id:
|
|
508
|
+
collector.add(
|
|
509
|
+
f"{prefix}: benchmarkIdentifier '{binding.benchmarkIdentifier}' "
|
|
510
|
+
f"does not match definition '{definition_id}'"
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
# 2. Property identifiers in propertyMapping must exist in the definition
|
|
514
|
+
if binding.propertyMapping:
|
|
515
|
+
for entry in binding.propertyMapping:
|
|
516
|
+
if isinstance(entry, FieldMapping):
|
|
517
|
+
pid = entry.benchmark.identifier
|
|
518
|
+
if pid not in property_ids:
|
|
519
|
+
collector.add(
|
|
520
|
+
f"{prefix}: propertyMapping references unknown benchmark property '{pid}'"
|
|
521
|
+
)
|
|
522
|
+
elif isinstance(entry, CategoricalValueMapping):
|
|
523
|
+
pid = entry.categoricalValue.property.identifier
|
|
524
|
+
if pid not in property_ids:
|
|
525
|
+
collector.add(
|
|
526
|
+
f"{prefix}: propertyMapping references unknown benchmark property '{pid}'"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
# 3. Metric identifiers in metricMapping must exist in the definition
|
|
530
|
+
if binding.metricMapping and metric_ids is not None:
|
|
531
|
+
for entry in binding.metricMapping:
|
|
532
|
+
mid = entry.benchmark.identifier
|
|
533
|
+
if mid not in metric_ids:
|
|
534
|
+
collector.add(
|
|
535
|
+
f"{prefix}: metricMapping references unknown benchmark metric '{mid}'"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def validate_logical_benchmark_file(
|
|
540
|
+
file: Path,
|
|
541
|
+
collector: ValidationErrorCollector,
|
|
542
|
+
) -> LogicalBenchmarkConfig | None:
|
|
543
|
+
"""Validate a logical benchmark YAML file against the schema and referential integrity rules.
|
|
544
|
+
|
|
545
|
+
Returns the parsed LogicalBenchmarkConfig if schema validation passes, None otherwise.
|
|
546
|
+
Integrity errors are collected but do not prevent returning the parsed object.
|
|
547
|
+
"""
|
|
548
|
+
data = load_yaml_file(file, collector)
|
|
549
|
+
if data is None:
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
try:
|
|
553
|
+
parsed = LogicalBenchmarkConfig.model_validate(data)
|
|
554
|
+
except ValidationError as exc:
|
|
555
|
+
for error in exc.errors():
|
|
556
|
+
collector.add(format_pydantic_error(error, file))
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
# Referential integrity checks
|
|
560
|
+
defn = parsed.logicalBenchmark
|
|
561
|
+
definition_id = defn.benchmarkIdentifier
|
|
562
|
+
property_ids = {p.identifier for p in defn.properties}
|
|
563
|
+
metric_ids = set(defn.metrics) if defn.metrics is not None else None
|
|
564
|
+
|
|
565
|
+
if parsed.bindings:
|
|
566
|
+
for i, binding in enumerate(parsed.bindings):
|
|
567
|
+
_check_binding_integrity(
|
|
568
|
+
binding,
|
|
569
|
+
definition_id,
|
|
570
|
+
property_ids,
|
|
571
|
+
metric_ids,
|
|
572
|
+
collector,
|
|
573
|
+
file,
|
|
574
|
+
i,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
return parsed
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def validate_logical_benchmarks(
|
|
581
|
+
benchmarks_root: Annotated[
|
|
582
|
+
Path,
|
|
583
|
+
typer.Option(
|
|
584
|
+
"--benchmarks-root",
|
|
585
|
+
help="Path to the directory containing logical benchmark YAML files.",
|
|
586
|
+
exists=True,
|
|
587
|
+
file_okay=False,
|
|
588
|
+
dir_okay=True,
|
|
589
|
+
resolve_path=True,
|
|
590
|
+
),
|
|
591
|
+
] = Path("./benchmarks"),
|
|
592
|
+
file: Annotated[
|
|
593
|
+
Path | None,
|
|
594
|
+
typer.Option(
|
|
595
|
+
"--file",
|
|
596
|
+
help="Validate a single logical benchmark YAML file instead of the whole directory.",
|
|
597
|
+
exists=True,
|
|
598
|
+
file_okay=True,
|
|
599
|
+
dir_okay=False,
|
|
600
|
+
readable=True,
|
|
601
|
+
resolve_path=True,
|
|
602
|
+
),
|
|
603
|
+
] = None,
|
|
604
|
+
output_format: Annotated[
|
|
605
|
+
Literal["json", "yaml", "table"] | None,
|
|
606
|
+
typer.Option(
|
|
607
|
+
"-o",
|
|
608
|
+
"--output-format",
|
|
609
|
+
help="Output format: 'json', 'yaml', or 'table' (default: table)",
|
|
610
|
+
),
|
|
611
|
+
] = None,
|
|
612
|
+
output_file: Annotated[
|
|
613
|
+
Path | None,
|
|
614
|
+
typer.Option(
|
|
615
|
+
"--output-file",
|
|
616
|
+
help="Write results to a file. Format is inferred from the file extension (.json/.yaml) unless --output-format is also set.",
|
|
617
|
+
dir_okay=False,
|
|
618
|
+
writable=True,
|
|
619
|
+
),
|
|
620
|
+
] = None,
|
|
621
|
+
) -> None:
|
|
622
|
+
"""Validate logical benchmark YAML files.
|
|
623
|
+
|
|
624
|
+
This command supports two modes:
|
|
625
|
+
1. Directory mode: validate all *.yaml files under --benchmarks-root (default: ./benchmarks)
|
|
626
|
+
2. Single-file mode: validate one file via --file
|
|
627
|
+
|
|
628
|
+
Checks performed for each file:
|
|
629
|
+
- Schema correctness (required fields, valid structure)
|
|
630
|
+
- Referential integrity (binding identifiers match the definition, property/metric
|
|
631
|
+
references exist in the definition)
|
|
632
|
+
|
|
633
|
+
Exit code is 0 on success, 1 if any file fails validation.
|
|
634
|
+
"""
|
|
635
|
+
# NOTE: no verbose mode — errors are always shown in the table's Issues column
|
|
636
|
+
if output_format:
|
|
637
|
+
validate_output_format(
|
|
638
|
+
output_format, allow_yaml=True, allow_csv=False, allow_table=True
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
# Collect files to validate
|
|
642
|
+
if file is not None:
|
|
643
|
+
files = [file]
|
|
644
|
+
else:
|
|
645
|
+
files = sorted(benchmarks_root.glob("*.yaml"))
|
|
646
|
+
if not files:
|
|
647
|
+
console.print(
|
|
648
|
+
f"[yellow]No YAML files found in {benchmarks_root.resolve()}[/yellow]"
|
|
649
|
+
)
|
|
650
|
+
raise typer.Exit(code=0)
|
|
651
|
+
|
|
652
|
+
all_results: list[dict[str, Any]] = []
|
|
653
|
+
total_failed = 0
|
|
654
|
+
|
|
655
|
+
for yaml_file in files:
|
|
656
|
+
collector = ValidationErrorCollector()
|
|
657
|
+
validate_logical_benchmark_file(yaml_file, collector)
|
|
658
|
+
|
|
659
|
+
success = not collector.has_errors
|
|
660
|
+
if not success:
|
|
661
|
+
total_failed += 1
|
|
662
|
+
|
|
663
|
+
result: dict[str, Any] = {
|
|
664
|
+
"instance_path": str(yaml_file),
|
|
665
|
+
"status": "success" if success else "failed",
|
|
666
|
+
"errors": collector.errors,
|
|
667
|
+
"warnings": [],
|
|
668
|
+
}
|
|
669
|
+
all_results.append(result)
|
|
670
|
+
|
|
671
|
+
output_data = {"files": all_results}
|
|
672
|
+
|
|
673
|
+
fmt = determine_output_format(output_format, output_file, default="table")
|
|
674
|
+
|
|
675
|
+
if output_file:
|
|
676
|
+
write_results_to_file(output_data, output_file, fmt)
|
|
677
|
+
elif fmt in ("json", "yaml"):
|
|
678
|
+
print_structured_results(output_data, fmt)
|
|
679
|
+
else:
|
|
680
|
+
_print_validation_table(all_results)
|
|
681
|
+
|
|
682
|
+
if total_failed > 0:
|
|
683
|
+
raise typer.Exit(code=1)
|