Files
partridge-cpp/benchmarks/run.py
T
Codex instance f37e08768d solver: break board dihedral symmetry
Constrain the unique unit square to a closed D4 fundamental region once its placement is known. This preserves one representative of every board-orientation orbit without assigning identities to repeated squares.

Keep a symmetry-disabled benchmark path, document the proof and measurements, and cover generic, diagonal, midline, corner, and centre orbits.

Tests: Release, Debug, ASan, and UBSan CTest (11 passed each)

Refs: #3
2026-07-30 18:18:45 +01:00

324 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Repeatable Partridge solver benchmark runner."""
import argparse
import json
import os
import platform
import statistics
import subprocess
import sys
import time
from pathlib import Path
def cache_values(build_dir):
values = {}
cache = build_dir / "CMakeCache.txt"
if not cache.exists():
return values
for line in cache.read_text(encoding="utf-8").splitlines():
if line.startswith(("//", "#")) or "=" not in line:
continue
key_type, value = line.split("=", 1)
key = key_type.split(":", 1)[0]
values[key] = value
return values
def command_output(command, cwd=None):
try:
return subprocess.run(
command, cwd=cwd, check=True, capture_output=True, text=True
).stdout.strip()
except (OSError, subprocess.CalledProcessError):
return "unknown"
def environment(binary):
build_dir = binary.resolve().parent
cache = cache_values(build_dir)
compiler = cache.get("CMAKE_CXX_COMPILER", "unknown")
build_type = cache.get("CMAKE_BUILD_TYPE", "unknown")
type_flags = cache.get(f"CMAKE_CXX_FLAGS_{build_type.upper()}", "")
flags = " ".join(
part for part in (cache.get("CMAKE_CXX_FLAGS", ""), type_flags) if part
)
source_dir = cache.get("CMAKE_HOME_DIRECTORY")
commit = command_output(
["git", "rev-parse", "HEAD"], cwd=source_dir if source_dir else None
)
status = command_output(
["git", "status", "--porcelain"], cwd=source_dir if source_dir else None
)
compiler_version = (
command_output([compiler, "--version"]).splitlines()[:1]
if compiler != "unknown"
else ["unknown"]
)[0]
return {
"os": platform.platform(),
"compiler": compiler,
"compiler_version": compiler_version,
"compiler_flags": flags,
"linker_flags": cache.get("CMAKE_EXE_LINKER_FLAGS", ""),
"build_type": build_type,
"commit": commit,
"working_tree_dirty": status not in ("", "unknown"),
"hardware": {
"model": command_output(["sysctl", "-n", "hw.model"])
if sys.platform == "darwin"
else platform.node(),
"machine": platform.machine() or "unknown",
"processor": platform.processor() or "unknown",
"logical_cpus": os.cpu_count(),
},
"workers": 1,
"search_policy": "single-threaded, deterministic, smallest-width valley",
"seed": None,
}
def run_once(
binary, order, mode, search_policy, search_route, symmetry, timeout
):
started = time.monotonic()
try:
process = subprocess.run(
[
str(binary),
str(order),
mode,
search_policy,
search_route,
symmetry,
],
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return {
"status": "timeout",
"wall_seconds": time.monotonic() - started,
}
if process.returncode != 0:
return {
"status": "error",
"returncode": process.returncode,
"stderr": process.stderr[-1000:],
"wall_seconds": time.monotonic() - started,
}
try:
result = json.loads(process.stdout)
except json.JSONDecodeError as error:
return {
"status": "invalid-output",
"detail": str(error),
"stdout": process.stdout[-1000:],
}
result["status"] = "ok"
result["wall_seconds"] = time.monotonic() - started
return result
def distribution(values):
if not values:
return None
median = statistics.median(values)
return {
"median": median,
"min": min(values),
"max": max(values),
"spread": max(values) - min(values),
"median_absolute_deviation": statistics.median(
abs(value - median) for value in values
),
}
def summarize(runs):
completed = [run for run in runs if run["status"] == "ok"]
return {
"attempted": len(runs),
"completed": len(completed),
"timeouts": sum(run["status"] == "timeout" for run in runs),
"errors": sum(run["status"] == "error" for run in runs),
"invalid_outputs": sum(
run["status"] == "invalid-output" for run in runs
),
"solve_seconds": distribution(
[run["timing_seconds"]["solve"] for run in completed]
),
"construction_seconds": distribution(
[run["timing_seconds"]["construction"] for run in completed]
),
"validation_seconds": distribution(
[run["timing_seconds"]["validation"] for run in completed]
),
"render_seconds": distribution(
[run["timing_seconds"]["render"] for run in completed]
),
"counter_values": completed[0]["counters"] if completed else None,
"counters_stable": bool(completed)
and all(run["counters"] == completed[0]["counters"] for run in completed),
}
def summary_failed(summary):
return (
summary["completed"] == 0
or summary["errors"] > 0
or summary["invalid_outputs"] > 0
)
def self_test():
sample = distribution([1.0, 3.0, 2.0])
assert sample["median"] == 2.0
assert sample["spread"] == 2.0
assert sample["median_absolute_deviation"] == 1.0
parsed = json.loads(
'{"timing_seconds":{"solve":1,"construction":0},"counters":{}}'
)
assert parsed["timing_seconds"]["solve"] == 1
runs = [
{
"status": "ok",
"timing_seconds": {
"solve": 1.0,
"construction": 0.0,
"validation": 0.1,
"render": 0.2,
},
"counters": {"search_nodes": 3},
},
{"status": "timeout"},
{"status": "error"},
{"status": "invalid-output"},
]
summary = summarize(runs)
assert summary["completed"] == 1
assert summary["timeouts"] == 1
assert summary["errors"] == 1
assert summary["invalid_outputs"] == 1
assert summary["construction_seconds"]["median"] == 0.0
assert summary_failed(summary)
assert summary_failed(summarize([{"status": "timeout"}]))
completed_with_timeout = summarize([runs[0], {"status": "timeout"}])
assert not summary_failed(completed_with_timeout)
assert trial_modes(["counters", "plain"], 0) == ["counters", "plain"]
assert trial_modes(["counters", "plain"], 1) == ["plain", "counters"]
def trial_modes(modes, trial):
if len(modes) < 2 or trial % 2 == 0:
return modes
return list(reversed(modes))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--binary", type=Path)
parser.add_argument("--orders", nargs="+", type=int, default=[7, 8, 9])
parser.add_argument("--warmup", type=int, default=1)
parser.add_argument("--repetitions", type=int, default=5)
parser.add_argument("--timeout", type=float, default=600.0)
parser.add_argument(
"--candidate-order",
dest="search_policy",
choices=("ascending", "descending", "best-fit"),
default="ascending",
)
parser.add_argument("--direct-search", action="store_true")
parser.add_argument(
"--no-symmetry",
action="store_true",
help="disable unique-unit-square D4 symmetry breaking",
)
parser.add_argument(
"--measure-overhead",
action="store_true",
help="also run the compile-time counter-free solver",
)
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args()
if args.self_test:
self_test()
return 0
if args.binary is None:
parser.error("--binary is required")
if args.warmup < 0 or args.repetitions < 1 or args.timeout <= 0:
parser.error("warmup must be non-negative; repetitions and timeout positive")
modes = ["counters", "plain"]
if not args.measure_overhead:
modes = ["counters"]
cases = []
failed = False
for order in args.orders:
mode_results = {mode: {"runs": []} for mode in modes}
for trial in range(args.warmup):
for mode in trial_modes(modes, trial):
run_once(
args.binary,
order,
mode,
args.search_policy,
"direct" if args.direct_search else "public",
"none" if args.no_symmetry else "d4",
args.timeout,
)
for trial in range(args.repetitions):
for mode in trial_modes(modes, trial):
mode_results[mode]["runs"].append(
run_once(
args.binary,
order,
mode,
args.search_policy,
"direct" if args.direct_search else "public",
"none" if args.no_symmetry else "d4",
args.timeout,
)
)
for result in mode_results.values():
result["summary"] = summarize(result["runs"])
summary = result["summary"]
failed = failed or summary_failed(summary)
overhead = None
counted = mode_results["counters"]["summary"]["solve_seconds"]
plain = mode_results.get("plain", {}).get("summary", {}).get("solve_seconds")
if counted and plain and plain["median"]:
overhead = {
"median_seconds": counted["median"] - plain["median"],
"median_percent": 100.0
* (counted["median"] / plain["median"] - 1.0),
}
cases.append({"order": order, "modes": mode_results, "counter_overhead": overhead})
document = {
"schema": "partridge-benchmark-v1",
"environment": environment(args.binary),
"policy": {
"orders": args.orders,
"warmup_runs": args.warmup,
"measured_repetitions": args.repetitions,
"per_run_timeout_seconds": args.timeout,
"candidate_order": args.search_policy,
"search_route": "direct" if args.direct_search else "public",
"symmetry_breaking": "none" if args.no_symmetry else "d4",
"stdout": "captured; rendered grid suppressed by probe",
},
"cases": cases,
}
json.dump(document, sys.stdout, indent=2, sort_keys=True)
sys.stdout.write("\n")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())