mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-03 07:02:10 -04:00
193 lines
8.5 KiB
Python
193 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Bounded validity/fulfillment gate; parallel elapsed times are not speed evidence."""
|
|
|
|
import argparse
|
|
import csv
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import zipfile
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
# Exact demand and sheet count: empty, skipped, and partial jobs must never pass.
|
|
EXPECTED = {
|
|
"single-triangle": (1, 1),
|
|
"paired-wedges": (2, 1),
|
|
"repeated-ell-fill": (7, 1),
|
|
"mixed-irregular": (6, 1),
|
|
"rotation-edge-fit": (1, 1),
|
|
"multi-sheet": (3, 3),
|
|
}
|
|
|
|
|
|
class GateError(Exception):
|
|
pass
|
|
|
|
|
|
def check_fixtures(directory):
|
|
files = list(directory.rglob("*.nest"))
|
|
names = [path.stem for path in files]
|
|
if len(names) != len(EXPECTED) or set(names) != set(EXPECTED):
|
|
raise GateError(f"Expected exactly six fixtures {sorted(EXPECTED)}; found {sorted(names)}")
|
|
if list(directory.rglob("*.manifest.json")):
|
|
raise GateError("Unexpected manifest in synthetic fixture directory")
|
|
hashes = {}
|
|
for path in sorted(files):
|
|
with zipfile.ZipFile(path) as archive:
|
|
metadata = json.loads(archive.read("nest.json").decode("utf-8-sig"))
|
|
drawings = metadata["drawings"]
|
|
demand = sum(drawing["quantity"]["required"] for drawing in drawings)
|
|
if demand != EXPECTED[path.stem][0] or not drawings:
|
|
raise GateError(f"Unexpected demand in {path.name}: {demand}")
|
|
if any(metadata.get(key) for key in ("customer", "madeBy")):
|
|
raise GateError(f"Non-synthetic identity metadata in {path.name}")
|
|
for drawing in drawings:
|
|
if drawing.get("customer") or drawing.get("source", {}).get("path"):
|
|
raise GateError(f"Non-synthetic drawing metadata in {path.name}")
|
|
if archive.comment or any(entry.comment for entry in archive.infolist()):
|
|
raise GateError(f"Unexpected ZIP comments in {path.name}")
|
|
hashes[path.name] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
return hashes
|
|
|
|
|
|
def check_csv(path):
|
|
seen = set()
|
|
outcomes = []
|
|
with path.open(newline="", encoding="utf-8-sig") as stream:
|
|
reader = csv.DictReader(stream)
|
|
required = {"Job", "Engine", "Valid", "Crashed", "FullyPlaced", "PartsPlaced",
|
|
"PartsRequested", "PlatesUsed", "Notes"}
|
|
if not required.issubset(reader.fieldnames or []):
|
|
raise GateError("Missing benchmark CSV columns")
|
|
for row in reader:
|
|
if row["Engine"] == "Baseline":
|
|
continue
|
|
if row["Engine"] != "Irregular":
|
|
raise GateError(f"Unexpected engine: {row['Engine']}")
|
|
name = row["Job"]
|
|
if name not in EXPECTED or name in seen:
|
|
raise GateError(f"Unknown or duplicate fixture result: {name}")
|
|
seen.add(name)
|
|
demand, sheets = EXPECTED[name]
|
|
if (row["Valid"] != "True" or row["Crashed"] != "False"
|
|
or row["FullyPlaced"] != "True" or row["Notes"].strip()):
|
|
raise GateError(f"Invalid, crashed, timed-out or incomplete result: {name}: {row}")
|
|
if (int(row["PartsRequested"]) != demand or int(row["PartsPlaced"]) != demand
|
|
or int(row["PlatesUsed"]) != sheets):
|
|
raise GateError(f"Unexpected fulfillment or sheet count: {name}: {row}")
|
|
outcomes.append(row)
|
|
if seen != set(EXPECTED):
|
|
raise GateError(f"Missing fixture results: {sorted(set(EXPECTED) - seen)}")
|
|
return outcomes
|
|
|
|
|
|
def terminate_tree(process):
|
|
if os.name == "nt":
|
|
if process.poll() is None:
|
|
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT,
|
|
timeout=10)
|
|
else:
|
|
# Also remove any descendants left after their parent exits. The session is ours alone.
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
process.wait(timeout=10)
|
|
|
|
|
|
def run_process(command, deadline, log):
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise GateError("Whole-check watchdog expired")
|
|
print("Running: " + " ".join(map(str, command)), flush=True)
|
|
with log.open("w", encoding="utf-8") as output:
|
|
process = subprocess.Popen(command, cwd=ROOT, stdout=output, stderr=subprocess.STDOUT,
|
|
start_new_session=os.name != "nt",
|
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0)
|
|
try:
|
|
code = process.wait(timeout=remaining)
|
|
if code:
|
|
raise GateError(f"Subprocess exited {code}; see {log}")
|
|
except subprocess.TimeoutExpired as error:
|
|
raise GateError(f"Whole-check watchdog expired; see {log}") from error
|
|
finally:
|
|
terminate_tree(process)
|
|
|
|
|
|
def bounded_int(low, high):
|
|
def parse(value):
|
|
number = int(value)
|
|
if not low <= number <= high:
|
|
raise argparse.ArgumentTypeError(f"must be between {low} and {high}")
|
|
return number
|
|
return parse
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--parallel", type=bounded_int(1, 4), default=2)
|
|
parser.add_argument("--timeout", type=bounded_int(1, 300), default=300,
|
|
help="whole build + solve budget, seconds (maximum 300)")
|
|
parser.add_argument("--fixtures", type=Path, default=ROOT / "test-data/synthetic-nests")
|
|
parser.add_argument("--output", type=Path, help="retain logs/CSV/provenance in this directory")
|
|
parser.add_argument("--check-csv", type=Path, help="check an existing CSV without solving")
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
if args.check_csv:
|
|
outcomes = check_csv(args.check_csv)
|
|
else:
|
|
deadline = time.monotonic() + args.timeout
|
|
hashes = check_fixtures(args.fixtures)
|
|
output = args.output or Path(tempfile.mkdtemp(prefix="opennest-synthetic-"))
|
|
output = output.resolve()
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
(output / "accepted.json").unlink(missing_ok=True)
|
|
print(f"Evidence directory: {output}", flush=True)
|
|
project = ROOT / "OpenNest.Benchmark/OpenNest.Benchmark.csproj"
|
|
run_process(["dotnet", "build", str(project), "-c", "Release",
|
|
"--disable-build-servers"], deadline, output / "build.log")
|
|
assembly = ROOT / "OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll"
|
|
command = ["dotnet", str(assembly), str(args.fixtures.resolve()),
|
|
"--engines", "Irregular", "--parallel", str(args.parallel),
|
|
"--progress", "--csv", str(output / "results.csv")]
|
|
run_process(command, deadline, output / "benchmark.log")
|
|
outcomes = check_csv(output / "results.csv")
|
|
if check_fixtures(args.fixtures) != hashes:
|
|
raise GateError("Fixture inputs changed during the check")
|
|
(output / "accepted.json").write_text(json.dumps({
|
|
"fixtures": hashes, "parallel": args.parallel,
|
|
"benchmark_command": list(map(str, command)), "outcomes": outcomes,
|
|
"benchmark_assembly_sha256": hashlib.sha256(assembly.read_bytes()).hexdigest(),
|
|
"checker_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
|
"timing": "diagnostic only; not speed evidence",
|
|
}, indent=2) + "\n", encoding="utf-8")
|
|
for row in sorted(outcomes, key=lambda row: row["Job"]):
|
|
print(f"PASS {row['Job']}: {row['PartsPlaced']}/{row['PartsRequested']} parts, "
|
|
f"{row['PlatesUsed']} sheet(s)")
|
|
print("PASS: exactly six Irregular results, all valid and fully placed")
|
|
return 0
|
|
except KeyboardInterrupt:
|
|
print("FAIL: cancelled; owned subprocess tree terminated", file=sys.stderr)
|
|
return 130
|
|
except (GateError, OSError, ValueError, KeyError, zipfile.BadZipFile,
|
|
subprocess.SubprocessError) as error:
|
|
print(f"FAIL: {error}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
def cancel(_signum, _frame):
|
|
raise KeyboardInterrupt()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Treat CI/terminal termination the same as Ctrl-C; run_process's finally owns cleanup.
|
|
signal.signal(signal.SIGTERM, cancel)
|
|
sys.exit(main())
|