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:
@@ -1 +1,5 @@
|
|||||||
cmake-build*/
|
cmake-build*/
|
||||||
|
build*/
|
||||||
|
.venv*/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -109,3 +109,6 @@ was necessarily dirty with the issue 6 implementation.
|
|||||||
New optimization issues should quote the exact JSON
|
New optimization issues should quote the exact JSON
|
||||||
environment, policy, median/spread, stable counters, and counted overhead from
|
environment, policy, median/spread, stable counters, and counted overhead from
|
||||||
this runner for both before and after revisions.
|
this runner for both before and after revisions.
|
||||||
|
|
||||||
|
The separate optional CP-SAT reference benchmark and its model, memory, worker,
|
||||||
|
and timing report are documented in [CP_SAT_REFERENCE.md](./CP_SAT_REFERENCE.md).
|
||||||
|
|||||||
@@ -29,5 +29,8 @@ if(BUILD_TESTING)
|
|||||||
add_test(NAME benchmark-format
|
add_test(NAME benchmark-format
|
||||||
COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/benchmarks/run.py"
|
COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/benchmarks/run.py"
|
||||||
--self-test)
|
--self-test)
|
||||||
|
add_test(NAME reference-support
|
||||||
|
COMMAND "${Python3_EXECUTABLE}"
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/tests/reference_test.py")
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Optional CP-SAT reference
|
||||||
|
|
||||||
|
Issue #5 evaluated a CP-SAT model as a correctness and performance reference.
|
||||||
|
It is deliberately isolated from the C++ build: CMake, the native executable,
|
||||||
|
and the normal CTest suite do not require OR-Tools.
|
||||||
|
|
||||||
|
## Setup and use
|
||||||
|
|
||||||
|
Google recommends installing the Python wheel with `pip` in a virtual
|
||||||
|
environment. From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m venv .venv-cp-sat
|
||||||
|
.venv-cp-sat/bin/python -m pip install -r requirements-cp-sat.txt
|
||||||
|
.venv-cp-sat/bin/python tools/cp_sat_reference.py 8 --workers 8 \
|
||||||
|
--time-limit 600 > cp-sat-order-8.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The report includes the OR-Tools version, available and selected workers,
|
||||||
|
first-solution setting, model variable/constraint and serialized sizes, build,
|
||||||
|
solve and independent-validation times, process peak RSS, solver statistics,
|
||||||
|
placements, and validator diagnostics. `ru_maxrss` is a high-water mark for
|
||||||
|
the complete Python process, not a solver-only allocation measurement.
|
||||||
|
|
||||||
|
For repeated measurements:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv-cp-sat/bin/python benchmarks/run_cp_sat.py \
|
||||||
|
--solver tools/cp_sat_reference.py --order 8 --workers 8 \
|
||||||
|
--repetitions 3 --time-limit 600 > cp-sat-benchmark.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated reports are not versioned. The dependency-free validator can also
|
||||||
|
check the `solution` member extracted from a report:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/partridge_validator.py solution.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recorded order-8 comparison
|
||||||
|
|
||||||
|
On 30 July 2026, three measured runs used OR-Tools 9.15.6755, Python
|
||||||
|
3.13.14, macOS arm64, all 8 available logical CPUs/workers, first-solution
|
||||||
|
search, and a 600-second limit per run. All solutions passed independent
|
||||||
|
validation and the model was stable across runs:
|
||||||
|
|
||||||
|
| Metric | Result |
|
||||||
|
| --- | ---: |
|
||||||
|
| Pieces | 36 |
|
||||||
|
| Variables | 2,664 |
|
||||||
|
| Constraints | 5,497 |
|
||||||
|
| Exact-fill literals | 2,592 |
|
||||||
|
| Edge exclusions | 140 |
|
||||||
|
| Serialized model | 244,006 bytes |
|
||||||
|
| Solve time | 2.080 s median (2.019–3.046 s) |
|
||||||
|
| Process peak RSS | 220,364,800–231,702,528 bytes |
|
||||||
|
|
||||||
|
One order-9 CP-SAT run with the same dependency and all 8 workers also passed
|
||||||
|
independent validation. Its model contained 45 pieces, 4,140 variables, 8,493
|
||||||
|
constraints, 4,050 exact-fill literals, 176 edge exclusions, and a 377,709-byte
|
||||||
|
serialized proto. It solved in 54.623 seconds and the process peak RSS was
|
||||||
|
363,888,640 bytes. This is a single observation rather than a distribution.
|
||||||
|
|
||||||
|
The native Release benchmark on the same machine used one worker and one
|
||||||
|
warm-up followed by three instrumented measurements. It also produced valid
|
||||||
|
solutions, with stable counters and a 1.854-second solve median
|
||||||
|
(1.852–1.866 seconds) for order 8. Order 9 uses the native odd-order
|
||||||
|
construction: its predecessor search median was 1.846 seconds
|
||||||
|
(1.840–1.848 seconds), and construction took 0.250 microseconds median. These
|
||||||
|
are first-feasible timings, not proof-time or solution-enumeration
|
||||||
|
measurements. CP-SAT is competitive at order 8 but uses eight workers and a
|
||||||
|
much larger runtime dependency, and it is substantially slower than native
|
||||||
|
construction at order 9; the comparison does not justify replacing the native
|
||||||
|
solver.
|
||||||
|
|
||||||
|
## Model
|
||||||
|
|
||||||
|
There are `side` named copies of every square size from 1 through the order.
|
||||||
|
Each copy has integer x/y starts and fixed-size x/y intervals constrained by
|
||||||
|
`NoOverlap2D`. Exact-fill constraints require the sizes of all intervals
|
||||||
|
covering every row and column to sum to the board side. Positions that would
|
||||||
|
leave an edge strip whose area cannot be filled by smaller pieces are excluded.
|
||||||
|
Copies of an equal size are strictly ordered by `(x, y)` encoded as
|
||||||
|
`x * board_side + y`. CP-SAT uses all explicitly selected workers and stops
|
||||||
|
after its first feasible solution.
|
||||||
|
|
||||||
|
The validator is independent of OR-Tools and checks dimensions, bounds,
|
||||||
|
multiplicity, pairwise cell occupancy, and complete coverage. It implements
|
||||||
|
the same validation contract as the independent native test validator, rather
|
||||||
|
than sharing its C++ implementation. Its known-order-8 and invalid-case tests
|
||||||
|
mirror the native validator tests. It can also be used for native placements,
|
||||||
|
so solver constraints are not treated as proof of correctness.
|
||||||
|
|
||||||
|
The model follows:
|
||||||
|
|
||||||
|
- <https://zayenz.se/blog/post/partridge-packing/> for exact fill, edge
|
||||||
|
exclusions, and identical-piece ordering;
|
||||||
|
- <https://or-tools.github.io/docs/pdoc/ortools/sat/python/cp_model> for the
|
||||||
|
current fixed-size interval and `add_no_overlap_2d` APIs;
|
||||||
|
- <https://developers.google.com/optimization/install/> for optional virtual
|
||||||
|
environment installation.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Keep the model as an optional, disposable research/reference tool; do not make
|
||||||
|
it a production solver or a required dependency. It provides an independently
|
||||||
|
validated second implementation and useful solver statistics, while the native
|
||||||
|
specialized search and odd-order construction remain simpler to distribute and
|
||||||
|
benchmark.
|
||||||
|
|
||||||
|
Do not pursue DLX without new evidence. A cell-placement exact-cover matrix is
|
||||||
|
large, and identical square copies introduce substantial symmetric
|
||||||
|
arrangements. The reported DLX and SAT attempts at
|
||||||
|
<https://www.tunbury.org/2025/12/17/partridge-puzzle/> did not find a practical
|
||||||
|
order-9 route even after ordering reduced memory. CP-SAT can express the
|
||||||
|
global no-overlap, per-line fill reasoning, edge exclusions, and copy ordering
|
||||||
|
directly; a fresh DLX experiment is not justified by the present evidence.
|
||||||
@@ -66,3 +66,7 @@ instructions.
|
|||||||
|
|
||||||
Performance measurements use the separate opt-in suite described in
|
Performance measurements use the separate opt-in suite described in
|
||||||
[BENCHMARKING.md](./BENCHMARKING.md).
|
[BENCHMARKING.md](./BENCHMARKING.md).
|
||||||
|
|
||||||
|
The research-only OR-Tools comparison is optional and documented in
|
||||||
|
[CP_SAT_REFERENCE.md](./CP_SAT_REFERENCE.md). It does not affect the native
|
||||||
|
build or its dependencies.
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ checks that its search counters exactly match order 8. The rendering test also
|
|||||||
formats the known order-8 solution and checks the resulting grid dimensions and
|
formats the known order-8 solution and checks the resulting grid dimensions and
|
||||||
coverage.
|
coverage.
|
||||||
|
|
||||||
|
When Python is available, `reference-support` also tests the dependency-free
|
||||||
|
placement JSON validator. If the optional OR-Tools package is present, it
|
||||||
|
additionally builds and solves the trivial order-1 CP-SAT model; otherwise that
|
||||||
|
part is skipped without changing the default test result.
|
||||||
|
|
||||||
## Debug and sanitizers
|
## Debug and sanitizers
|
||||||
|
|
||||||
Use a separate build directory for each configuration:
|
Use a separate build directory for each configuration:
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Repeat the optional CP-SAT reference and summarize its JSON reports."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import statistics
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--solver", type=Path, required=True)
|
||||||
|
parser.add_argument("--order", type=int, default=8)
|
||||||
|
parser.add_argument("--workers", type=int, required=True)
|
||||||
|
parser.add_argument("--repetitions", type=int, default=3)
|
||||||
|
parser.add_argument("--time-limit", type=float, default=600)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.repetitions < 1:
|
||||||
|
parser.error("repetitions must be positive")
|
||||||
|
|
||||||
|
runs = []
|
||||||
|
process_errors = []
|
||||||
|
for _ in range(args.repetitions):
|
||||||
|
command = [
|
||||||
|
sys.executable,
|
||||||
|
str(args.solver),
|
||||||
|
str(args.order),
|
||||||
|
"--workers",
|
||||||
|
str(args.workers),
|
||||||
|
"--time-limit",
|
||||||
|
str(args.time_limit),
|
||||||
|
]
|
||||||
|
process = subprocess.run(command, text=True, capture_output=True)
|
||||||
|
try:
|
||||||
|
report = json.loads(process.stdout)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
parser.exit(1, f"reference produced invalid JSON: {error}\n{process.stderr}")
|
||||||
|
runs.append(report)
|
||||||
|
if process.returncode != 0:
|
||||||
|
process_errors.append(
|
||||||
|
{
|
||||||
|
"returncode": process.returncode,
|
||||||
|
"stderr": process.stderr,
|
||||||
|
"status": report.get("result", {}).get("status"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = [run for run in runs if run["result"]["valid"]]
|
||||||
|
solve_times = [run["timing_seconds"]["solve"] for run in completed]
|
||||||
|
first_model = runs[0]["model"]
|
||||||
|
model_stable = all(run["model"] == first_model for run in runs)
|
||||||
|
output = {
|
||||||
|
"schema": "partridge-cp-sat-benchmark-v1",
|
||||||
|
"order": args.order,
|
||||||
|
"workers": args.workers,
|
||||||
|
"repetitions": args.repetitions,
|
||||||
|
"completed": len(completed),
|
||||||
|
"model": first_model,
|
||||||
|
"model_stable": model_stable,
|
||||||
|
"process_errors": process_errors,
|
||||||
|
"peak_rss_bytes": [run["memory"]["peak_rss_bytes"] for run in runs],
|
||||||
|
"solve_seconds": solve_times,
|
||||||
|
"solve_median_seconds": statistics.median(solve_times) if solve_times else None,
|
||||||
|
"runs": runs,
|
||||||
|
}
|
||||||
|
json.dump(output, sys.stdout, indent=2, sort_keys=True)
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
return (
|
||||||
|
0
|
||||||
|
if len(completed) == args.repetitions
|
||||||
|
and not process_errors
|
||||||
|
and model_stable
|
||||||
|
else 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Optional research dependency; not used by the build or default solver.
|
||||||
|
ortools>=9.14,<10
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Dependency-free tests for the CP-SAT reference support code."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
from partridge_validator import validate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
KNOWN_ORDER_8 = {
|
||||||
|
"order": 8,
|
||||||
|
"board_side": 36,
|
||||||
|
"placements": [
|
||||||
|
{"x": x, "y": y, "side": side}
|
||||||
|
for x, y, side in [
|
||||||
|
(0, 0, 8), (8, 0, 8), (16, 0, 8), (24, 0, 8),
|
||||||
|
(32, 0, 4), (32, 4, 4), (0, 8, 8), (8, 8, 8),
|
||||||
|
(16, 8, 8), (24, 8, 6), (30, 8, 6), (24, 14, 5),
|
||||||
|
(29, 14, 7), (0, 16, 6), (6, 16, 3), (9, 16, 8),
|
||||||
|
(17, 16, 7), (6, 19, 3), (24, 19, 5), (29, 21, 7),
|
||||||
|
(0, 22, 7), (7, 22, 2), (17, 23, 1), (18, 23, 6),
|
||||||
|
(7, 24, 6), (13, 24, 5), (24, 24, 5), (29, 28, 3),
|
||||||
|
(32, 28, 4), (0, 29, 7), (13, 29, 7), (20, 29, 7),
|
||||||
|
(27, 29, 2), (7, 30, 6), (27, 31, 5), (32, 32, 4),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
assert validate(KNOWN_ORDER_8) == []
|
||||||
|
|
||||||
|
invalid = json.loads(json.dumps(KNOWN_ORDER_8))
|
||||||
|
invalid["placements"].pop()
|
||||||
|
diagnostics = validate(invalid)
|
||||||
|
assert any("side 4 has multiplicity 3; expected 4" in d for d in diagnostics)
|
||||||
|
assert any("not completely covered" in d for d in diagnostics)
|
||||||
|
|
||||||
|
invalid = json.loads(json.dumps(KNOWN_ORDER_8))
|
||||||
|
invalid["placements"][0]["x"] = 36
|
||||||
|
diagnostics = validate(invalid)
|
||||||
|
assert any("outside the board" in diagnostic for diagnostic in diagnostics)
|
||||||
|
assert any("not completely covered" in diagnostic for diagnostic in diagnostics)
|
||||||
|
|
||||||
|
invalid = json.loads(json.dumps(KNOWN_ORDER_8))
|
||||||
|
invalid["placements"][1]["x"] = invalid["placements"][0]["x"]
|
||||||
|
invalid["placements"][1]["y"] = invalid["placements"][0]["y"]
|
||||||
|
diagnostics = validate(invalid)
|
||||||
|
assert any("overlaps placement 0" in diagnostic for diagnostic in diagnostics)
|
||||||
|
|
||||||
|
invalid = json.loads(json.dumps(KNOWN_ORDER_8))
|
||||||
|
invalid["placements"][0]["side"] = 9
|
||||||
|
diagnostics = validate(invalid)
|
||||||
|
assert any("invalid side length" in diagnostic for diagnostic in diagnostics)
|
||||||
|
|
||||||
|
if importlib.util.find_spec("ortools") is not None:
|
||||||
|
process = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(ROOT / "tools" / "cp_sat_reference.py"),
|
||||||
|
"1",
|
||||||
|
"--workers",
|
||||||
|
"1",
|
||||||
|
],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
report = json.loads(process.stdout)
|
||||||
|
assert report["result"]["valid"]
|
||||||
|
assert validate(report["solution"]) == []
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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