diff --git a/BENCHMARKING.md b/BENCHMARKING.md index d88be7a..ce464da 100644 --- a/BENCHMARKING.md +++ b/BENCHMARKING.md @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 613f5bf..561a7ea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/TESTING.md b/TESTING.md index 60f0a90..67710c0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 diff --git a/benchmarks/benchmark.cc b/benchmarks/benchmark.cc index 3cb36b0..67a22ef 100644 --- a/benchmarks/benchmark.cc +++ b/benchmarks/benchmark.cc @@ -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::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 diff --git a/benchmarks/run.py b/benchmarks/run.py index 678492b..6e1e38e 100755 --- a/benchmarks/run.py +++ b/benchmarks/run.py @@ -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, diff --git a/main.cc b/main.cc index 4c2cb5d..d206f83 100644 --- a/main.cc +++ b/main.cc @@ -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 + template auto search_skyline(size_t const n, size_t const length, SearchPolicy const policy, std::vector &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(n, length, policy, skyline, - available, squares, counters)) { + if (search_skyline( + 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 + template auto search_solution_impl(size_t const n, SearchPolicy const policy, SearchCounters *const counters) noexcept -> Results { @@ -293,7 +330,7 @@ namespace { } std::vector squares; squares.reserve(length); - static_cast(search_skyline( + static_cast(search_skyline( 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(n, policy, nullptr); + if (symmetry == SymmetryBreaking::d4_unit_square) { + return search_solution_impl(n, policy, nullptr); + } + return search_solution_impl(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(n, policy, &counters); + if (symmetry == SymmetryBreaking::d4_unit_square) { + return search_solution_impl(n, policy, &counters); + } + return search_solution_impl(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 diff --git a/tests/tests.cc b/tests/tests.cc index 661a571..f57b394 100644 --- a/tests/tests.cc +++ b/tests/tests.cc @@ -166,6 +166,35 @@ namespace { }); } + auto d4_orbit(Placement const &placement, std::uint64_t const length) + -> std::array { + 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 { + auto orbit = d4_orbit(placement, length); + std::vector 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(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{{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, 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; }