exoskeleton/code/experiments/cli.py

91 lines
3.3 KiB
Python

"""Command-line interface for planning, running, and validating studies."""
from __future__ import annotations
import argparse
import importlib
import json
from pathlib import Path
from typing import Any
from .io import atomic_write_json
from .plan import build_trial_plan, load_document, validate_trial_plan
from .runner import run_trial_plan
from .validate import validate_batch
def _load_executor(specification: str):
if ":" not in specification:
raise ValueError("executor must use module:function syntax")
module_name, attribute_name = specification.split(":", 1)
module = importlib.import_module(module_name)
executor = getattr(module, attribute_name)
if not callable(executor):
raise TypeError(f"{specification} is not callable")
return executor
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Reproducible experiment runner")
subparsers = parser.add_subparsers(dest="command", required=True)
plan_parser = subparsers.add_parser("plan", help="expand a study specification")
plan_parser.add_argument("--spec", type=Path, required=True)
plan_parser.add_argument("--output", type=Path, required=True)
run_parser = subparsers.add_parser("run", help="execute an immutable plan")
run_parser.add_argument("--plan", type=Path, required=True)
run_parser.add_argument("--batch-dir", type=Path, required=True)
run_parser.add_argument("--executor", default=None)
run_parser.add_argument("--dry-run", action="store_true")
run_parser.add_argument("--no-resume", action="store_true")
run_parser.add_argument("--stop-on-error", action="store_true")
run_parser.add_argument("--max-trials", type=int, default=None)
validate_parser = subparsers.add_parser("validate", help="validate a batch")
validate_parser.add_argument("--batch-dir", type=Path, required=True)
validate_parser.add_argument("--allow-incomplete", action="store_true")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
result: dict[str, Any]
if args.command == "plan":
plan = build_trial_plan(load_document(args.spec))
atomic_write_json(args.output, plan)
result = {
"kind": "plan_summary",
"schema_version": plan["schema_version"],
"study_id": plan["study_id"],
"split": plan["split"],
"pair_count": plan["pair_count"],
"trial_count": plan["trial_count"],
"plan_hash": plan["plan_hash"],
"output": str(args.output.resolve()),
}
elif args.command == "run":
plan = load_document(args.plan)
validate_trial_plan(plan)
executor = None if args.executor is None else _load_executor(args.executor)
result = run_trial_plan(
plan,
args.batch_dir,
executor,
resume=not args.no_resume,
dry_run=args.dry_run,
continue_on_error=not args.stop_on_error,
max_trials=args.max_trials,
)
else:
result = validate_batch(
args.batch_dir,
require_complete=not args.allow_incomplete,
)
print(json.dumps(result, indent=2, ensure_ascii=False, sort_keys=True))
return 0 if result.get("valid", True) else 1
if __name__ == "__main__":
raise SystemExit(main())