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
This commit was merged in pull request #25.
This commit is contained in:
Codex instance
2026-07-30 18:18:45 +01:00
parent 74bde266d0
commit f37e08768d
7 changed files with 284 additions and 28 deletions
+47
View File
@@ -18,6 +18,9 @@ the probe renders into an in-memory stream so grids do not perturb terminal I/O.
Override the policy with `--orders`, `--warmup`, `--repetitions`, `--timeout`,
and `--candidate-order`. The choices are `ascending`, `descending`, and
`best-fit`; ascending candidate sizes are the production default.
The production default also removes equivalent D4 board orientations by
constraining the unique unit square. Pass `--no-symmetry` to obtain an
otherwise identical unconstrained baseline.
Order 9 uses the constructive odd-order path, searching order 8 and then tiling
the enlarged border, so it is suitable for normal local benchmarking:
@@ -69,6 +72,50 @@ Do not use wall-clock thresholds as correctness checks. Keep the generated
JSON outside version control unless it is being deliberately added as a named
comparison baseline.
## D4 board symmetry
Every solution contains exactly one 1-by-1 square. Rotations and reflections
of the whole board preserve square sizes, multiplicities, and coverage, so the
unit square can select the orientation without assigning identities to any of
the repeated larger squares. For a board of width `W`, the solver accepts the
unit square only in the closed fundamental triangle
`x <= y <= floor((W - 1) / 2)`. Reflecting a cell toward the left edge,
swapping its coordinates if necessary, and reflecting toward the top edge
maps every D4 orbit into this triangle. Closed diagonal and midline
boundaries retain the smaller orbits of symmetric cells.
The check is made only when the skyline search is ready to place the unit
square, so it never rejects a partial state before the square's position is
decidable. The implementation remains compile-time counter-free in normal
solver calls and keeps the single-threaded deterministic search policy.
Instrumented runs count examined and rejected unit-square placements as prune
checks and hits.
Measurements used the issue #3 dirty working tree based on commit `74bde26`,
Apple Clang 21.0.0, `-O3 -DNDEBUG`, macOS arm64, one worker, one warm-up, and
five sequential measured repetitions. All results passed the benchmark's
independent cell-coverage validator and counts were stable:
| Route | D4 constraint | Median solve (range) | Nodes | Prune hits |
| --- | --- | ---: | ---: | ---: |
| Order 8 public | enabled | 0.245 s (0.244-0.248 s) | 2,931,203 | 1,329,567 |
| Order 8 public | disabled | 0.679 s (0.659-0.709 s) | 7,735,369 | 0 |
| Order 9 direct | enabled | 1.705 s (1.671-1.740 s) | 16,231,918 | 6,755,171 |
| Order 9 direct | disabled | 4.542 s (4.482-4.662 s) | 45,840,266 | 0 |
Thus the constraint reduced nodes by 62% for order 8 and 65% for direct order
9; counted median solve time fell by 64% and 62%, respectively.
A public order-10 probe with the D4 constraint, ascending policy, and a
45-second per-process bound did not complete. Public order 11 first performs
that identical order-10 core search and only then adds its inexpensive odd
border, so adding either order to routine correctness tests would duplicate
the same unresolved bottleneck. They remain useful opt-in heavyweight
benchmark targets with explicit timeouts. A direct order-11 benchmark is a
different experiment: it bypasses the public odd construction and searches
the larger core itself, so it must not be presented as public order-11
performance.
## Smallest-valley skyline
The solver stores one filled height per board column instead of one value per
+1
View File
@@ -25,6 +25,7 @@ if(BUILD_TESTING)
add_test(NAME solver-completion COMMAND partridge_tests solver-completion)
add_test(NAME search-counters COMMAND partridge_tests search-counters)
add_test(NAME skyline-search COMMAND partridge_tests skyline-search)
add_test(NAME d4-symmetry COMMAND partridge_tests d4-symmetry)
find_package(Python3 COMPONENTS Interpreter)
if(Python3_Interpreter_FOUND)
add_test(NAME benchmark-format
+5 -1
View File
@@ -18,7 +18,11 @@ check smallest-width valley selection and deterministic tie-breaking, validate
an order-8 result with descending candidates, and independently validate a
direct order-9 search with ascending candidates. An
exhaustive infeasible order also checks that all three policies visit the same
search space. The direct order-9 test is deliberately separate from the
search space. Focused D4 tests cover all eight rotations and reflections,
even and odd midlines, diagonals, corners, and the odd-board centre. They
independently validate order-8 solutions with symmetry enabled and disabled
and check that only the enabled search records symmetry pruning. The direct
order-9 test is deliberately separate from the
public order-9 route, which uses even-predecessor construction. Orders 10 and
11 are not routine tests because both public routes require the same long
direct order-10 search; the route-boundary test still checks that order 11
+22 -7
View File
@@ -28,6 +28,7 @@ namespace {
auto solve(std::uint64_t order, bool instrument,
SearchPolicy policy, bool direct_search,
SymmetryBreaking symmetry,
SearchCounters &counters)
-> TimedSolution {
auto const predecessor_order =
@@ -36,8 +37,8 @@ namespace {
auto predecessor =
instrument
? search_solution_instrumented(predecessor_order, counters,
policy)
: search_solution(predecessor_order, policy);
policy, symmetry)
: search_solution(predecessor_order, policy, symmetry);
auto const search_end = Clock::now();
auto const construction_begin = Clock::now();
@@ -93,9 +94,10 @@ namespace {
}
int main(int argc, char **argv) {
if (argc < 3 || argc > 5) {
if (argc < 3 || argc > 6) {
std::cerr << "usage: partridge_benchmark ORDER counters|plain "
"[ascending|descending|best-fit] [public|direct]\n";
"[ascending|descending|best-fit] [public|direct] "
"[d4|none]\n";
return 2;
}
auto const order = static_cast<std::uint64_t>(std::strtoull(argv[1], nullptr, 10));
@@ -117,15 +119,25 @@ int main(int argc, char **argv) {
return 2;
}
auto const direct_search =
argc == 5 && std::string_view(argv[4]) == "direct";
if (argc == 5 && std::string_view(argv[4]) != "public" &&
argc >= 5 && std::string_view(argv[4]) == "direct";
if (argc >= 5 && std::string_view(argv[4]) != "public" &&
std::string_view(argv[4]) != "direct") {
std::cerr << "search route must be public or direct\n";
return 2;
}
auto const symmetry =
argc < 6 || std::string_view(argv[5]) == "d4"
? SymmetryBreaking::d4_unit_square
: SymmetryBreaking::disabled;
if (argc == 6 && std::string_view(argv[5]) != "d4" &&
std::string_view(argv[5]) != "none") {
std::cerr << "symmetry mode must be d4 or none\n";
return 2;
}
SearchCounters counters;
auto timed = solve(order, instrument, policy, direct_search, counters);
auto timed =
solve(order, instrument, policy, direct_search, symmetry, counters);
auto const validation_begin = Clock::now();
auto const validation_ok = valid(order, timed.result);
@@ -150,6 +162,9 @@ int main(int argc, char **argv) {
<< "\""
<< ",\"search_route\":\""
<< (direct_search ? "direct" : "public") << "\""
<< ",\"symmetry_breaking\":\""
<< (symmetry == SymmetryBreaking::d4_unit_square ? "d4" : "none")
<< "\""
<< ",\"solved\":" << boolean(!timed.result.squares().empty())
<< ",\"valid\":" << boolean(validation_ok)
<< ",\"timing_seconds\":{\"solve\":" << timed.search_seconds
+19 -2
View File
@@ -79,11 +79,20 @@ def environment(binary):
}
def run_once(binary, order, mode, search_policy, search_route, timeout):
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],
[
str(binary),
str(order),
mode,
search_policy,
search_route,
symmetry,
],
check=False,
capture_output=True,
text=True,
@@ -223,6 +232,11 @@ def main():
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",
@@ -254,6 +268,7 @@ def main():
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):
@@ -265,6 +280,7 @@ def main():
mode,
args.search_policy,
"direct" if args.direct_search else "public",
"none" if args.no_symmetry else "d4",
args.timeout,
)
)
@@ -293,6 +309,7 @@ def main():
"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,
+68 -15
View File
@@ -162,6 +162,29 @@ namespace {
best_fit,
};
/** Whether to remove equivalent board orientations from the search. */
enum class SymmetryBreaking {
disabled,
d4_unit_square,
};
/** Return whether a cell is the canonical representative of its D4 orbit.
*
* Reflect a cell into the left half of the board, rotate so its distance
* from the left edge is no greater than its distance from the top edge, and
* finally reflect it into the top half. The resulting fundamental region is
* the closed triangle x <= y <= floor((length - 1) / 2). Closed boundaries
* retain representatives whose orbit is smaller than eight.
*/
[[nodiscard]] auto canonical_unit_position(size_t const x,
size_t const y,
size_t const length) noexcept
-> bool {
assert(x < length);
assert(y < length);
return x <= y && y <= (length - 1) / 2;
}
/** A maximal level skyline segment which is lower than its neighbours. */
struct Valley {
size_t x;
@@ -200,7 +223,7 @@ namespace {
return best;
}
template<bool Instrument>
template<bool Instrument, bool BreakD4Symmetry>
auto search_skyline(size_t const n, size_t const length,
SearchPolicy const policy,
std::vector<size_t> &skyline, Avail &available,
@@ -226,6 +249,20 @@ namespace {
return false;
}
if constexpr (BreakD4Symmetry) {
if (side == 1) {
if constexpr (Instrument) {
++counters->prune_checks;
}
if (!canonical_unit_position(valley.x, valley.height, length)) {
if constexpr (Instrument) {
++counters->prune_hits;
}
return false;
}
}
}
if constexpr (Instrument) {
++counters->attempted_placements;
}
@@ -234,8 +271,8 @@ namespace {
side, valley.height + side);
squares.emplace_back(valley.x + valley.height * length, side);
if (search_skyline<Instrument>(n, length, policy, skyline,
available, squares, counters)) {
if (search_skyline<Instrument, BreakD4Symmetry>(
n, length, policy, skyline, available, squares, counters)) {
return true;
}
@@ -281,7 +318,7 @@ namespace {
* candidates and updating up to n columns for each costs O(n^2), for
* O(board width + n^2) local work per node and the same total state.
*/
template<bool Instrument>
template<bool Instrument, bool BreakD4Symmetry>
auto search_solution_impl(size_t const n, SearchPolicy const policy,
SearchCounters *const counters) noexcept
-> Results {
@@ -293,7 +330,7 @@ namespace {
}
std::vector<Square> squares;
squares.reserve(length);
static_cast<void>(search_skyline<Instrument>(
static_cast<void>(search_skyline<Instrument, BreakD4Symmetry>(
n, length, policy, skyline, available, squares, counters));
return {length, std::move(squares)};
@@ -301,18 +338,28 @@ namespace {
auto search_solution(
size_t const n,
SearchPolicy const policy = SearchPolicy::ascending) noexcept
SearchPolicy const policy = SearchPolicy::ascending,
SymmetryBreaking const symmetry =
SymmetryBreaking::d4_unit_square) noexcept
-> Results {
return search_solution_impl<false>(n, policy, nullptr);
if (symmetry == SymmetryBreaking::d4_unit_square) {
return search_solution_impl<false, true>(n, policy, nullptr);
}
return search_solution_impl<false, false>(n, policy, nullptr);
}
auto search_solution_instrumented(size_t const n,
SearchCounters &counters,
SearchPolicy const policy =
SearchPolicy::ascending) noexcept
SearchPolicy::ascending,
SymmetryBreaking const symmetry =
SymmetryBreaking::d4_unit_square) noexcept
-> Results {
counters = {};
return search_solution_impl<true>(n, policy, &counters);
if (symmetry == SymmetryBreaking::d4_unit_square) {
return search_solution_impl<true, true>(n, policy, &counters);
}
return search_solution_impl<true, false>(n, policy, &counters);
}
/** Construct an odd-order solution from its even-order predecessor. */
@@ -347,23 +394,29 @@ namespace {
auto find_solution(
size_t const n,
SearchPolicy const policy = SearchPolicy::ascending) noexcept -> Results {
SearchPolicy const policy = SearchPolicy::ascending,
SymmetryBreaking const symmetry =
SymmetryBreaking::d4_unit_square) noexcept -> Results {
if (uses_odd_construction(n)) {
return construct_odd_solution(n, search_solution(n - 1, policy));
return construct_odd_solution(
n, search_solution(n - 1, policy, symmetry));
}
return search_solution(n, policy);
return search_solution(n, policy, symmetry);
}
auto find_solution_instrumented(size_t const n,
SearchCounters &counters,
SearchPolicy const policy =
SearchPolicy::ascending) noexcept
SearchPolicy::ascending,
SymmetryBreaking const symmetry =
SymmetryBreaking::d4_unit_square) noexcept
-> Results {
if (uses_odd_construction(n)) {
return construct_odd_solution(
n, search_solution_instrumented(n - 1, counters, policy));
n, search_solution_instrumented(
n - 1, counters, policy, symmetry));
}
return search_solution_instrumented(n, counters, policy);
return search_solution_instrumented(n, counters, policy, symmetry);
}
} // anon namespace
+122 -3
View File
@@ -166,6 +166,35 @@ namespace {
});
}
auto d4_orbit(Placement const &placement, std::uint64_t const length)
-> std::array<Placement, 8> {
auto const far_x = length - 1 - placement.x;
auto const far_y = length - 1 - placement.y;
return {{
{placement.x, placement.y, placement.side},
{far_y, placement.x, placement.side},
{far_x, far_y, placement.side},
{placement.y, far_x, placement.side},
{far_x, placement.y, placement.side},
{far_y, far_x, placement.side},
{placement.x, far_y, placement.side},
{placement.y, placement.x, placement.side},
}};
}
auto canonical_members(Placement const &placement, std::uint64_t const length)
-> std::vector<Placement> {
auto orbit = d4_orbit(placement, length);
std::vector<Placement> members;
for (auto const &member: orbit) {
if (canonical_unit_position(member.x, member.y, length) &&
std::ranges::find(members, member) == members.end()) {
members.push_back(member);
}
}
return members;
}
auto test_validator() -> int {
int failures = 0;
auto const valid = known_order_8();
@@ -337,11 +366,98 @@ namespace {
failures += expect(counters.attempted_placements > 0 &&
counters.backtracks > 0,
"instrumented solver did not count placements/backtracks");
failures += expect(counters.prune_checks == 0 &&
counters.prune_hits == 0 &&
failures += expect(counters.prune_checks >= counters.prune_hits &&
counters.generated_tasks == 0 &&
counters.completed_tasks == 0,
"unimplemented solver counters were not zero");
"search counters are inconsistent");
return failures;
}
auto test_d4_symmetry() -> int {
int failures = 0;
// A generic cell has eight distinct images: identity, three rotations,
// and four reflected rotations. Exactly one must survive.
auto const generic = d4_orbit({5, 9, 1}, 36);
auto distinct_generic = std::vector<Placement>(generic.begin(),
generic.end());
std::ranges::sort(
distinct_generic, {}, [](Placement const &placement) {
return std::array{placement.x, placement.y};
});
distinct_generic.erase(
std::ranges::unique(distinct_generic).begin(),
distinct_generic.end());
failures += expect(distinct_generic.size() == generic.size(),
"generic D4 test point does not have eight images");
for (std::size_t index = 0; index < generic.size(); ++index) {
failures += expect(
canonical_members(generic[index], 36) ==
std::vector<Placement>{{5, 9, 1}},
"D4 transform " + std::to_string(index) +
" did not select the same canonical representative");
}
// Closed boundaries are important because diagonal, midline, corner, and
// centre cells have non-trivial stabilizers and therefore smaller orbits.
for (auto const test_case:
std::array<std::pair<std::uint64_t, Placement>, 8>{{
{36, {0, 0, 1}},
{36, {0, 17, 1}},
{36, {7, 7, 1}},
{36, {7, 17, 1}},
{45, {0, 22, 1}},
{45, {11, 11, 1}},
{45, {11, 22, 1}},
{45, {22, 22, 1}},
}}) {
failures += expect(
canonical_members(test_case.second, test_case.first).size() == 1,
"boundary orbit did not retain exactly one distinct representative");
}
failures += expect(
canonical_unit_position(7, 17, 36) &&
!canonical_unit_position(8, 7, 36) &&
!canonical_unit_position(7, 18, 36) &&
canonical_unit_position(22, 22, 45) &&
!canonical_unit_position(22, 23, 45),
"canonical triangle mishandled a diagonal or midline boundary");
SearchCounters enabled_counters;
SearchCounters disabled_counters;
auto const enabled = search_solution_instrumented(
8, enabled_counters, SearchPolicy::ascending,
SymmetryBreaking::d4_unit_square);
auto const disabled = search_solution_instrumented(
8, disabled_counters, SearchPolicy::ascending,
SymmetryBreaking::disabled);
auto enabled_validation = validate(8, enabled);
auto disabled_validation = validate(8, disabled);
failures += expect(
enabled_validation.valid(),
"symmetry-enabled solution failed independent validation:\n" +
enabled_validation.text());
failures += expect(
disabled_validation.valid(),
"symmetry-disabled solution failed independent validation:\n" +
disabled_validation.text());
auto const unit = std::ranges::find_if(
enabled.squares(),
[](Square const &square) { return square.length() == 1; });
failures += expect(
unit != enabled.squares().end() &&
canonical_unit_position(unit->pos() % enabled.length(),
unit->pos() / enabled.length(),
enabled.length()),
"symmetry-enabled search placed the unique unit square outside the "
"canonical region");
failures += expect(
enabled_counters.prune_checks > 0 && enabled_counters.prune_hits > 0 &&
disabled_counters.prune_checks == 0 &&
disabled_counters.prune_hits == 0,
"symmetry instrumentation did not distinguish enabled and disabled "
"search");
return failures;
}
@@ -440,6 +556,9 @@ int main(int argc, char **argv) {
if (test == "skyline-search") {
return test_skyline_search();
}
if (test == "d4-symmetry") {
return test_d4_symmetry();
}
std::cerr << "unknown test: " << test << '\n';
return 2;
}