bench: add optional CP-SAT reference
Provide a separately installed OR-Tools model to compare a generic constraint solver with the native first-solution path without adding a production or default-test dependency. Use no-overlap, exact-fill, edge and equal-copy symmetry constraints, validate placements independently, and record model size, memory, worker count and timings for orders 8 and 9. Keep the tool only as a reference and defer DLX absent new evidence. Tests: Release CTest (9 passed) Tests: Python reference tests and compilation checks Tests: independently validated CP-SAT orders 8 and 9 Refs: #5
This commit was merged in pull request #22.
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Optional OR-Tools CP-SAT reference solver for the Partridge puzzle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import resource
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from partridge_validator import validate # noqa: E402
|
||||
|
||||
|
||||
@dataclass
|
||||
class Piece:
|
||||
side: int
|
||||
copy: int
|
||||
x: Any
|
||||
y: Any
|
||||
|
||||
|
||||
def maximum_rss_bytes() -> int:
|
||||
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||
# Darwin reports bytes; Linux and the other supported Unix wheels use KiB.
|
||||
return int(rss if sys.platform == "darwin" else rss * 1024)
|
||||
|
||||
|
||||
def minimum_edge_distance(side: int) -> int:
|
||||
for distance in range(1, side + 1):
|
||||
available = (distance * (distance + 1) // 2) ** 2
|
||||
if distance * side > available:
|
||||
return distance
|
||||
raise AssertionError("every non-unit side has a limiting edge distance")
|
||||
|
||||
|
||||
def add_edge_exclusions(model: Any, start: Any, side: int, board_side: int) -> int:
|
||||
if side == 1:
|
||||
return 0
|
||||
distance = minimum_edge_distance(side)
|
||||
forbidden = list(range(1, distance + 1))
|
||||
forbidden += list(
|
||||
range(board_side - side - distance, board_side - side)
|
||||
)
|
||||
for position in sorted(set(forbidden)):
|
||||
model.add(start != position)
|
||||
return len(set(forbidden))
|
||||
|
||||
|
||||
def build_model(order: int, cp_model: Any) -> tuple[Any, list[Piece], dict[str, int]]:
|
||||
from ortools.util.python.sorted_interval_list import Domain
|
||||
|
||||
board_side = order * (order + 1) // 2
|
||||
model = cp_model.CpModel()
|
||||
pieces: list[Piece] = []
|
||||
x_intervals = []
|
||||
y_intervals = []
|
||||
edge_exclusions = 0
|
||||
|
||||
for side in range(order, 0, -1):
|
||||
equal_size: list[Piece] = []
|
||||
for copy in range(side):
|
||||
name = f"s{side}_{copy}"
|
||||
x = model.new_int_var(0, board_side - side, f"x_{name}")
|
||||
y = model.new_int_var(0, board_side - side, f"y_{name}")
|
||||
piece = Piece(side, copy, x, y)
|
||||
pieces.append(piece)
|
||||
equal_size.append(piece)
|
||||
x_intervals.append(
|
||||
model.new_fixed_size_interval_var(x, side, f"xi_{name}")
|
||||
)
|
||||
y_intervals.append(
|
||||
model.new_fixed_size_interval_var(y, side, f"yi_{name}")
|
||||
)
|
||||
edge_exclusions += add_edge_exclusions(model, x, side, board_side)
|
||||
edge_exclusions += add_edge_exclusions(model, y, side, board_side)
|
||||
|
||||
# Strict lexicographic ordering of the (x, y) coordinate pairs.
|
||||
for before, after in zip(equal_size, equal_size[1:]):
|
||||
model.add(
|
||||
before.x * board_side + before.y
|
||||
< after.x * board_side + after.y
|
||||
)
|
||||
|
||||
model.add_no_overlap_2d(x_intervals, y_intervals)
|
||||
|
||||
exact_fill_literals = 0
|
||||
for axis in ("x", "y"):
|
||||
for line in range(board_side):
|
||||
contributions = []
|
||||
for index, piece in enumerate(pieces):
|
||||
start = getattr(piece, axis)
|
||||
overlaps = model.new_bool_var(f"{axis}_{line}_covers_{index}")
|
||||
overlap_domain = Domain.from_intervals(
|
||||
[[line - piece.side + 1, line]]
|
||||
)
|
||||
model.add_linear_expression_in_domain(
|
||||
start, overlap_domain
|
||||
).only_enforce_if(overlaps)
|
||||
model.add_linear_expression_in_domain(
|
||||
start, overlap_domain.complement()
|
||||
).only_enforce_if(overlaps.negated())
|
||||
contributions.append(piece.side * overlaps)
|
||||
exact_fill_literals += 1
|
||||
model.add(sum(contributions) == board_side)
|
||||
|
||||
return model, pieces, {
|
||||
"pieces": len(pieces),
|
||||
"edge_exclusions": edge_exclusions,
|
||||
"exact_fill_literals": exact_fill_literals,
|
||||
}
|
||||
|
||||
|
||||
def model_proto(model: Any) -> Any:
|
||||
if hasattr(model, "proto"):
|
||||
return model.proto
|
||||
return model.Proto()
|
||||
|
||||
|
||||
def serialized_model_size(model: Any) -> int:
|
||||
"""Return the binary proto size across old protobuf and new pybind APIs."""
|
||||
proto = model_proto(model)
|
||||
if hasattr(proto, "SerializeToString"):
|
||||
return len(proto.SerializeToString())
|
||||
descriptor, path = tempfile.mkstemp(suffix=".bin")
|
||||
os.close(descriptor)
|
||||
try:
|
||||
if not model.export_to_file(path):
|
||||
raise RuntimeError("OR-Tools could not export the model proto")
|
||||
return os.path.getsize(path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def solve(order: int, workers: int, time_limit: float | None) -> dict[str, Any]:
|
||||
try:
|
||||
from ortools.sat.python import cp_model
|
||||
except ImportError as error:
|
||||
raise RuntimeError(
|
||||
"OR-Tools is optional and is not installed; see "
|
||||
"CP_SAT_REFERENCE.md for virtual-environment instructions"
|
||||
) from error
|
||||
|
||||
initial_rss = maximum_rss_bytes()
|
||||
build_begin = time.perf_counter()
|
||||
model, pieces, counts = build_model(order, cp_model)
|
||||
build_seconds = time.perf_counter() - build_begin
|
||||
proto = model_proto(model)
|
||||
model_stats = {
|
||||
**counts,
|
||||
"variables": len(proto.variables),
|
||||
"constraints": len(proto.constraints),
|
||||
"serialized_bytes": serialized_model_size(model),
|
||||
}
|
||||
|
||||
solver = cp_model.CpSolver()
|
||||
solver.parameters.num_search_workers = workers
|
||||
solver.parameters.stop_after_first_solution = True
|
||||
if time_limit is not None:
|
||||
solver.parameters.max_time_in_seconds = time_limit
|
||||
|
||||
solve_begin = time.perf_counter()
|
||||
status = solver.solve(model)
|
||||
solve_seconds = time.perf_counter() - solve_begin
|
||||
feasible = status in (cp_model.FEASIBLE, cp_model.OPTIMAL)
|
||||
board_side = order * (order + 1) // 2
|
||||
placements = (
|
||||
[
|
||||
{
|
||||
"x": solver.value(piece.x),
|
||||
"y": solver.value(piece.y),
|
||||
"side": piece.side,
|
||||
}
|
||||
for piece in pieces
|
||||
]
|
||||
if feasible
|
||||
else []
|
||||
)
|
||||
solution = {
|
||||
"order": order,
|
||||
"board_side": board_side,
|
||||
"placements": placements,
|
||||
}
|
||||
validation_begin = time.perf_counter()
|
||||
diagnostics = validate(solution) if feasible else ["no solution produced"]
|
||||
validation_seconds = time.perf_counter() - validation_begin
|
||||
peak_rss = maximum_rss_bytes()
|
||||
|
||||
return {
|
||||
"schema": "partridge-cp-sat-reference-v1",
|
||||
"dependency": {
|
||||
"name": "ortools",
|
||||
"version": importlib.metadata.version("ortools"),
|
||||
"optional": True,
|
||||
},
|
||||
"configuration": {
|
||||
"workers": workers,
|
||||
"available_cpus": os.cpu_count(),
|
||||
"first_solution": True,
|
||||
"time_limit_seconds": time_limit,
|
||||
},
|
||||
"model": model_stats,
|
||||
"result": {
|
||||
"status": solver.status_name(status),
|
||||
"feasible": feasible,
|
||||
"valid": feasible and not diagnostics,
|
||||
"diagnostics": diagnostics,
|
||||
},
|
||||
"timing_seconds": {
|
||||
"build": build_seconds,
|
||||
"solve": solve_seconds,
|
||||
"solver_wall": solver.wall_time,
|
||||
"validation": validation_seconds,
|
||||
},
|
||||
"memory": {
|
||||
"initial_rss_bytes": initial_rss,
|
||||
"peak_rss_bytes": peak_rss,
|
||||
"peak_rss_increase_bytes": max(0, peak_rss - initial_rss),
|
||||
},
|
||||
"solution": solution,
|
||||
"solver_stats": solver.response_stats(),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("order", type=int)
|
||||
parser.add_argument(
|
||||
"--workers",
|
||||
type=int,
|
||||
default=max(1, os.cpu_count() or 1),
|
||||
help="CP-SAT search workers (default: all available logical CPUs)",
|
||||
)
|
||||
parser.add_argument("--time-limit", type=float)
|
||||
args = parser.parse_args()
|
||||
if args.order < 1:
|
||||
parser.error("order must be positive")
|
||||
if args.workers < 1:
|
||||
parser.error("workers must be positive")
|
||||
if args.time_limit is not None and args.time_limit <= 0:
|
||||
parser.error("time limit must be positive")
|
||||
|
||||
try:
|
||||
report = solve(args.order, args.workers, args.time_limit)
|
||||
except RuntimeError as error:
|
||||
parser.exit(2, f"{error}\n")
|
||||
json.dump(report, sys.stdout, indent=2, sort_keys=True)
|
||||
sys.stdout.write("\n")
|
||||
return 0 if report["result"]["valid"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dependency-free independent validator for Partridge placement JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def validate(document: dict[str, Any]) -> list[str]:
|
||||
"""Return diagnostics for a placement document, or an empty list."""
|
||||
diagnostics: list[str] = []
|
||||
try:
|
||||
order = int(document["order"])
|
||||
board_side = int(document["board_side"])
|
||||
placements = document["placements"]
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
return [f"invalid document: {error}"]
|
||||
|
||||
expected_side = order * (order + 1) // 2
|
||||
if order < 1:
|
||||
diagnostics.append("order must be positive")
|
||||
if board_side != expected_side:
|
||||
diagnostics.append(
|
||||
f"board side is {board_side}; expected triangular number {expected_side}"
|
||||
)
|
||||
if not isinstance(placements, list):
|
||||
return diagnostics + ["placements must be a list"]
|
||||
if board_side < 0:
|
||||
return diagnostics + ["board side must not be negative"]
|
||||
|
||||
multiplicities = [0] * (max(order, 0) + 1)
|
||||
occupied = [-1] * (board_side * board_side)
|
||||
for index, raw in enumerate(placements):
|
||||
try:
|
||||
x = int(raw["x"])
|
||||
y = int(raw["y"])
|
||||
side = int(raw["side"])
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
diagnostics.append(f"placement {index} is malformed: {error}")
|
||||
continue
|
||||
|
||||
description = f"placement {index} at ({x}, {y}) with side {side}"
|
||||
if side < 1 or side > order:
|
||||
diagnostics.append(f"{description} has an invalid side length")
|
||||
continue
|
||||
multiplicities[side] += 1
|
||||
if x < 0 or y < 0 or x + side > board_side or y + side > board_side:
|
||||
diagnostics.append(f"{description} is outside the board bounds")
|
||||
continue
|
||||
|
||||
overlap = -1
|
||||
for row in range(y, y + side):
|
||||
for column in range(x, x + side):
|
||||
cell = column + row * board_side
|
||||
if occupied[cell] != -1:
|
||||
overlap = occupied[cell]
|
||||
else:
|
||||
occupied[cell] = index
|
||||
if overlap != -1:
|
||||
diagnostics.append(f"{description} overlaps placement {overlap}")
|
||||
|
||||
for side in range(1, order + 1):
|
||||
if multiplicities[side] != side:
|
||||
diagnostics.append(
|
||||
f"side {side} has multiplicity {multiplicities[side]}; expected {side}"
|
||||
)
|
||||
if -1 in occupied:
|
||||
diagnostics.append("board is not completely covered")
|
||||
return diagnostics
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"input",
|
||||
nargs="?",
|
||||
type=argparse.FileType("r", encoding="utf-8"),
|
||||
default=sys.stdin,
|
||||
)
|
||||
args = parser.parse_args()
|
||||
document = json.load(args.input)
|
||||
diagnostics = validate(document)
|
||||
json.dump(
|
||||
{"valid": not diagnostics, "diagnostics": diagnostics},
|
||||
sys.stdout,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
sys.stdout.write("\n")
|
||||
return 0 if not diagnostics else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user