zcbot/windows-node/adapters/origin.analysis@v1/acceptance.py

398 lines
14 KiB
Python

"""Run fixed origin.analysis@v1 acceptance cases on a dedicated Windows node."""
from __future__ import annotations
import argparse
import csv
import hashlib
import importlib.util
import json
import locale
import os
import platform
import re
import subprocess
import sys
import time
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import Any
from uuid import NAMESPACE_URL, uuid5
ADAPTER_DIR = Path(__file__).resolve().parent
WORKER_PATH = ADAPTER_DIR / "worker.py"
_WORKER_SPEC = importlib.util.spec_from_file_location(
"zcbot_origin_analysis_acceptance_worker", WORKER_PATH
)
if _WORKER_SPEC is None or _WORKER_SPEC.loader is None:
raise RuntimeError("ORIGIN_ANALYSIS_WORKER_IMPORT_FAILED")
worker = importlib.util.module_from_spec(_WORKER_SPEC)
_WORKER_SPEC.loader.exec_module(worker)
REPORT_SCHEMA_VERSION = 1
ORIGIN_PROCESS_PATTERN = r"^origin(?:\d+)?(?:_?\d+)?(?:64)?\.exe$"
def _artifact_id(case_name: str) -> str:
return str(uuid5(NAMESPACE_URL, f"zcbot-origin-analysis-acceptance:{case_name}"))
def _request(case_name: str, operation: str, parameters: dict[str, Any]) -> dict[str, Any]:
return {
"schema_version": 1,
"inputs": [{"key": "sample", "artifact_id": _artifact_id(case_name)}],
"operation": {
"analysis": {
"type": operation,
"title": f"Origin analysis acceptance: {operation}",
"data": {"input": "sample", "x": "x", "y": "y"},
"parameters": parameters,
}
},
"outputs": (
[{"key": "result_workbook", "type": "table", "format": "xlsx"}]
if operation == "linear_fit"
else []
),
}
def _cases() -> dict[str, dict[str, Any]]:
data_check = _request(
"data_check",
"data_check",
{"fail_on": ["missing", "non_finite", "duplicate_x", "uneven_spacing"]},
)
return {
"data_check": {
"csv": "x,y\n0,1\n1,\n1,inf\n3,4\n",
"request": data_check,
"oracle": {
"passed": False,
"failed_checks": ["duplicate_x", "missing", "non_finite", "uneven_spacing"],
},
},
"normalize": {
"csv": "x,y\n0,0\n1,2\n2,4\n",
"request": _request(
"normalize", "normalize", {"method": "reference", "reference_x": 1.5}
),
"oracle": {"divisor": 3.0, "last_value": 4 / 3},
},
"smooth": {
"csv": "x,y\n" + "\n".join(f"{x},{x*x + 2*x + 3}" for x in range(7)) + "\n",
"request": _request(
"smooth",
"smooth",
{"method": "savitzky_golay", "window": 5, "polynomial_order": 2},
),
"oracle": {"rmse": 0.0},
},
"differentiate": {
"csv": "x,y\n" + "\n".join(f"{x},{x*x}" for x in range(5)) + "\n",
"request": _request("differentiate", "differentiate", {"order": 1}),
"oracle": {"derived": [0.0, 2.0, 4.0, 6.0, 8.0]},
},
"integrate": {
"csv": "x,y\n" + "\n".join(f"{x},{2*x}" for x in range(5)) + "\n",
"request": _request("integrate", "integrate", {"from": 1.0, "to": 3.0}),
"oracle": {"integral": 8.0, "full_integral": 16.0},
},
"linear_fit": {
"csv": "x,y\n" + "\n".join(f"{x},{2.5*x - 1.25}" for x in range(1, 7)) + "\n",
"request": _request(
"linear_fit",
"linear_fit",
{"include_intercept": True, "confidence_level": 0.95},
),
"oracle": {"slope": 2.5, "intercept": -1.25, "r_squared": 1.0},
},
}
def _canonical_request(request: dict[str, Any]) -> tuple[str, str]:
encoded = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return encoded, hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _stage(root: Path, case_name: str, case: dict[str, Any]) -> Path:
job_dir = root / case_name
request_dir = job_dir / "request"
input_dir = job_dir / "input" / "sample"
request_dir.mkdir(parents=True)
input_dir.mkdir(parents=True)
(input_dir / "sample.csv").write_text(case["csv"], encoding="utf-8", newline="")
_, digest = _canonical_request(case["request"])
record = {
"job_id": str(uuid5(NAMESPACE_URL, f"zcbot-origin-analysis-job:{case_name}")),
"lease_id": str(uuid5(NAMESPACE_URL, f"zcbot-origin-analysis-lease:{case_name}")),
"request_digest": digest,
"request": case["request"],
}
worker._atomic_json(request_dir / "request.json", record)
return job_dir
def _read_result(path: Path) -> tuple[list[str], list[list[float | str | None]]]:
with path.open("r", encoding="utf-8-sig", newline="") as handle:
rows = list(csv.reader(handle))
converted: list[list[float | str | None]] = []
for row in rows[1:]:
values: list[float | str | None] = []
for value in row:
if value == "":
values.append(None)
continue
try:
values.append(float(value))
except ValueError:
values.append(value)
converted.append(values)
return rows[0], converted
def _assert_close(actual: float, expected: float, tolerance: float = 1e-9) -> None:
if abs(float(actual) - float(expected)) > tolerance:
raise RuntimeError(f"NUMERICAL_ORACLE_MISMATCH:{actual}:{expected}")
def _validate_oracle(
case_name: str,
diagnostics: dict[str, Any],
result_headers: list[str],
result_rows: list[list[Any]],
oracle: dict[str, Any],
) -> dict[str, Any]:
if case_name == "data_check":
if diagnostics["passed"] is not oracle["passed"]:
raise RuntimeError("DATA_CHECK_PASS_STATE_MISMATCH")
if diagnostics["failed_checks"] != oracle["failed_checks"]:
raise RuntimeError("DATA_CHECK_CODES_MISMATCH")
elif case_name == "normalize":
_assert_close(diagnostics["divisor"], oracle["divisor"])
_assert_close(result_rows[-1][result_headers.index("normalized_y")], oracle["last_value"])
elif case_name == "smooth":
_assert_close(diagnostics["rmse"], oracle["rmse"], 1e-10)
elif case_name == "differentiate":
values = [row[result_headers.index("derivative_order_1")] for row in result_rows]
for actual, expected in zip(values, oracle["derived"], strict=True):
_assert_close(actual, expected)
elif case_name == "integrate":
_assert_close(diagnostics["integral"], oracle["integral"])
_assert_close(diagnostics["full_integral"], oracle["full_integral"])
elif case_name == "linear_fit":
for name in ("slope", "intercept", "r_squared"):
_assert_close(diagnostics[name], oracle[name])
return {"passed": True, "oracle": oracle}
def _validate_manifest(job_dir: Path, case: dict[str, Any]) -> dict[str, Any]:
terminal = json.loads((job_dir / "terminal.json").read_text(encoding="utf-8"))
if terminal.get("status") != "succeeded":
raise RuntimeError(f"WORKER_FAILED:{terminal.get('error')}")
expected = {"project", "result_table", "diagnostics", "analysis_spec", "provenance"}
if case["request"]["outputs"]:
expected.add("result_workbook")
manifest = terminal.get("artifact_manifest") or []
by_id = {item["artifact_id"]: item for item in manifest}
if set(by_id) != expected:
raise RuntimeError(f"ARTIFACT_MANIFEST_MISMATCH:{sorted(by_id)}")
output = job_dir / "output"
paths = {artifact_id: output / item["filename"] for artifact_id, item in by_id.items()}
for artifact_id, path in paths.items():
if not path.is_file() or path.stat().st_size != by_id[artifact_id]["size_bytes"]:
raise RuntimeError(f"ARTIFACT_INVALID:{artifact_id}")
if worker._file_sha256(path) != by_id[artifact_id]["sha256"]:
raise RuntimeError(f"ARTIFACT_DIGEST_MISMATCH:{artifact_id}")
diagnostics = json.loads(paths["diagnostics"].read_text(encoding="utf-8"))
headers, rows = _read_result(paths["result_table"])
oracle = _validate_oracle(case["request"]["operation"]["analysis"]["type"], diagnostics, headers, rows, case["oracle"])
return {
"artifacts": {
key: {"size_bytes": path.stat().st_size, "sha256": worker._file_sha256(path)}
for key, path in sorted(paths.items())
},
"oracle_validation": oracle,
}
def _reopen_project(job_dir: Path) -> dict[str, Any]:
import originpro as op
project = job_dir / "output" / "analysis.opju"
op.set_show(False)
try:
if not op.open(str(project), readonly=True):
raise RuntimeError("OPJU_REOPEN_FAILED")
books = list(op.pages("w"))
sheets = [sheet for book in books for sheet in book]
names = [
str(sheet.lname or getattr(book, "lname", "") or getattr(book, "name", "") or "")
for book in books
for sheet in book
]
for required in ("RawData", "Result", "Diagnostics"):
if required not in names:
raise RuntimeError(f"OPJU_SHEET_MISSING:{required}")
return {"worksheet_count": len(sheets), "worksheet_names": names}
finally:
if op.oext:
op.exit()
def _environment_fingerprint() -> dict[str, Any]:
try:
originpro_version = version("originpro")
except PackageNotFoundError:
originpro_version = "unknown"
return {
"adapter_version": worker.ADAPTER_VERSION,
"algorithm_version": worker.ALGORITHM_VERSION,
"originpro_version": originpro_version,
"numpy_version": worker.np.__version__,
"python_version": platform.python_version(),
"platform": platform.platform(),
"locale": locale.getlocale(),
"execution_mode": "hidden",
"configured_origin_executable": bool(os.environ.get("ZCBOT_ORIGIN_EXE")),
}
def _origin_processes() -> dict[int, str]:
completed = None
for attempt in range(3):
completed = subprocess.run(
["tasklist.exe", "/fo", "csv", "/nh"],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if completed.returncode == 0:
break
if attempt < 2:
time.sleep(1)
if completed is None or completed.returncode != 0:
detail = completed.stderr[-500:] if completed is not None else "not started"
raise RuntimeError(f"TASKLIST_FAILED:{detail}")
result: dict[int, str] = {}
for row in csv.reader(completed.stdout.splitlines()):
name = row[0].casefold() if row else ""
if len(row) >= 2 and re.fullmatch(ORIGIN_PROCESS_PATTERN, name):
result[int(row[1])] = row[0]
return result
def _wait_for_origin_release(baseline: dict[int, str], timeout_seconds: int) -> None:
deadline = time.monotonic() + timeout_seconds
while True:
remaining = {
pid: name for pid, name in _origin_processes().items() if pid not in baseline
}
if not remaining:
return
if time.monotonic() >= deadline:
raise RuntimeError(f"ORIGIN_PROCESS_REMAINS:{remaining}")
time.sleep(2)
def _run_case(
root: Path,
case_name: str,
case: dict[str, Any],
baseline: dict[int, str],
release_wait: int,
) -> dict[str, Any]:
job_dir = _stage(root, case_name, case)
started = time.monotonic()
completed = subprocess.run(
[sys.executable, str(WORKER_PATH), str(job_dir)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if completed.returncode != 0:
raise RuntimeError(f"WORKER_PROCESS_FAILED:{completed.stderr[-500:]}")
validation = _validate_manifest(job_dir, case)
reopened = _reopen_project(job_dir)
_wait_for_origin_release(baseline, release_wait)
_, digest = _canonical_request(case["request"])
return {
"case": case_name,
"elapsed_seconds": round(time.monotonic() - started, 3),
"request_digest": digest,
"validation": validation,
"reopen": reopened,
"origin_processes_released": True,
}
def run_suite(
root: Path,
selected: list[str] | None = None,
*,
release_wait: int = 60,
) -> dict[str, Any]:
cases = _cases()
names = selected or list(cases)
unknown = sorted(set(names) - cases.keys())
if unknown:
raise ValueError(f"UNKNOWN_ACCEPTANCE_CASES:{','.join(unknown)}")
root.mkdir(parents=True, exist_ok=False)
baseline = _origin_processes()
report = {
"schema_version": REPORT_SCHEMA_VERSION,
"started_at": datetime.now(timezone.utc).isoformat(),
"environment": _environment_fingerprint(),
"selected_cases": names,
"cases": [],
"passed": False,
}
report_path = root / "acceptance-report.json"
try:
for index, name in enumerate(names, start=1):
report["cases"].append(
_run_case(root, name, cases[name], baseline, release_wait)
)
worker._atomic_json(report_path, report)
print(f"[OK] Origin analysis acceptance {index}/{len(names)}: {name}")
report["passed"] = True
return report
except Exception as exc:
report["failure"] = {
"type": type(exc).__name__,
"detail": str(exc)[:1000],
"completed_cases": len(report["cases"]),
}
raise
finally:
report["completed_at"] = datetime.now(timezone.utc).isoformat()
worker._atomic_json(report_path, report)
def main() -> int:
parser = argparse.ArgumentParser(description="Run fixed Origin analysis acceptance cases.")
parser.add_argument("--work-root", type=Path, required=True)
parser.add_argument("--release-wait", type=int, default=60)
parser.add_argument(
"--case", action="append", choices=tuple(_cases()), dest="cases"
)
args = parser.parse_args()
if sys.platform != "win32":
raise RuntimeError("Origin analysis acceptance requires Windows")
if args.release_wait < 1:
raise ValueError("release-wait must be positive")
root = args.work_root.resolve()
report = run_suite(root, args.cases, release_wait=args.release_wait)
print(f"[OK] Origin analysis acceptance passed. Report: {root / 'acceptance-report.json'}")
print(f"[INFO] Cases: {len(report['cases'])}")
return 0
if __name__ == "__main__":
raise SystemExit(main())