From e27427d2311904f26312b738107051bedb2961dd Mon Sep 17 00:00:00 2001 From: Codex instance Date: Fri, 31 Jul 2026 08:11:30 +0100 Subject: [PATCH] solver: prune insufficient valley capacity A selected valley cannot admit a square wider than itself until it reaches the lower neighbouring rim. Reject states whose remaining narrow-square area cannot fill that strip, including width-one and width-two gaps. Keep an unpruned benchmark mode and dedicated counters so the rule remains independently measurable. Record the soundness argument and the measured default-on improvement. Tests: Debug CTest (12 passed) Tests: ASan+UBSan CTest (12 passed) Refs: #10 --- BENCHMARKING.md | 39 ++++++++++++++- CMakeLists.txt | 1 + benchmarks/benchmark.cc | 28 +++++++++-- benchmarks/run.py | 11 ++++- main.cc | 103 +++++++++++++++++++++++++++++++++------- tests/tests.cc | 78 +++++++++++++++++++++++++++++- 6 files changed, 234 insertions(+), 26 deletions(-) diff --git a/BENCHMARKING.md b/BENCHMARKING.md index ce464da..9891770 100644 --- a/BENCHMARKING.md +++ b/BENCHMARKING.md @@ -21,6 +21,8 @@ and `--candidate-order`. The choices are `ascending`, `descending`, and 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. +The production default also applies the valley-capacity rule described below. +Pass `--no-pruning` to obtain an otherwise identical unpruned search. 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: @@ -45,8 +47,9 @@ solve time. Constructed odd-order cases report predecessor search and construction separately. The document also records compiler, flags, build type, commit, OS/CPU metadata, worker count, search policy, seed, timeouts, errors, invalid outputs, and the stdout policy. Search counts must -be stable across repeated runs. Prune and task counters are zero for the -current unpruned, single-threaded solver and reserve stable schema fields for +be stable across repeated runs. Prune counters report enabled search rules; +they are zero in the corresponding disabled modes. Task counters remain zero +for the current single-threaded solver and reserve stable schema fields for later work. The runner writes its JSON report before returning a failure status if any mode @@ -72,6 +75,38 @@ 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. +## Valley-capacity pruning + +For a selected valley, let its rim be the lower height of its two neighbours, +treating a board edge as full height. Until the valley reaches that rim, no +square wider than the valley can enter it. Therefore the combined area of all +remaining squares no wider than the valley must be at least the valley width +times its depth below the rim. Rejecting a state when that necessary +inequality fails is sound. At widths one and two it provides the usual +narrow-gap capacity checks without separate special cases. + +The rule is compiled out of the recursive search in `--no-pruning` mode. +Instrumented output records `valley_capacity_checks` and +`valley_capacity_prunes` as well as the aggregate pruning counters, allowing +enabled and disabled runs to report nodes, checks, hits, and elapsed time. + +Measurements used the issue #10 working tree based on commit `f37e087`, Apple +Clang 21.0.0, `-O3 -DNDEBUG`, macOS arm64, one worker, one warm-up, and five +measured repetitions. The production ascending policy and D4 constraint were +enabled. Every result passed the benchmark's independent validator and all +counters were stable: + +| Order | Valley capacity | Median solve (range) | Nodes | Checks | Prunes | +| --- | --- | ---: | ---: | ---: | ---: | +| 7 exhaustive | enabled | 0.996 s (0.990-0.998 s) | 13,833,048 | 13,833,048 | 7,411,551 | +| 7 exhaustive | disabled | 1.110 s (1.109-1.111 s) | 14,997,603 | 0 | 0 | +| 8 first solution | enabled | 0.205 s (0.204-0.208 s) | 2,724,096 | 2,724,095 | 1,606,836 | +| 8 first solution | disabled | 0.228 s (0.228-0.233 s) | 2,931,203 | 0 | 0 | + +The rule reduced nodes by 7.8% for order 7 and 7.1% for order 8. Its low +per-node cost also reduced median counted solve time by 10.3% and 9.9%, +respectively, so it remains enabled by default. + ## D4 board symmetry Every solution contains exactly one 1-by-1 square. Rotations and reflections diff --git a/CMakeLists.txt b/CMakeLists.txt index 561a7ea..7b56619 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,7 @@ if(BUILD_TESTING) 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) + add_test(NAME valley-capacity COMMAND partridge_tests valley-capacity) find_package(Python3 COMPONENTS Interpreter) if(Python3_Interpreter_FOUND) add_test(NAME benchmark-format diff --git a/benchmarks/benchmark.cc b/benchmarks/benchmark.cc index 67a22ef..495fd75 100644 --- a/benchmarks/benchmark.cc +++ b/benchmarks/benchmark.cc @@ -29,6 +29,7 @@ namespace { auto solve(std::uint64_t order, bool instrument, SearchPolicy policy, bool direct_search, SymmetryBreaking symmetry, + Pruning pruning, SearchCounters &counters) -> TimedSolution { auto const predecessor_order = @@ -37,8 +38,8 @@ namespace { auto predecessor = instrument ? search_solution_instrumented(predecessor_order, counters, - policy, symmetry) - : search_solution(predecessor_order, policy, symmetry); + policy, symmetry, pruning) + : search_solution(predecessor_order, policy, symmetry, pruning); auto const search_end = Clock::now(); auto const construction_begin = Clock::now(); @@ -94,10 +95,10 @@ namespace { } int main(int argc, char **argv) { - if (argc < 3 || argc > 6) { + if (argc < 3 || argc > 7) { std::cerr << "usage: partridge_benchmark ORDER counters|plain " "[ascending|descending|best-fit] [public|direct] " - "[d4|none]\n"; + "[d4|none] [valley-capacity|none]\n"; return 2; } auto const order = static_cast(std::strtoull(argv[1], nullptr, 10)); @@ -134,10 +135,20 @@ int main(int argc, char **argv) { std::cerr << "symmetry mode must be d4 or none\n"; return 2; } + auto const pruning = + argc < 7 || std::string_view(argv[6]) == "valley-capacity" + ? Pruning::valley_capacity + : Pruning::disabled; + if (argc == 7 && std::string_view(argv[6]) != "valley-capacity" && + std::string_view(argv[6]) != "none") { + std::cerr << "pruning mode must be valley-capacity or none\n"; + return 2; + } SearchCounters counters; auto timed = - solve(order, instrument, policy, direct_search, symmetry, counters); + solve(order, instrument, policy, direct_search, symmetry, pruning, + counters); auto const validation_begin = Clock::now(); auto const validation_ok = valid(order, timed.result); @@ -165,6 +176,9 @@ int main(int argc, char **argv) { << ",\"symmetry_breaking\":\"" << (symmetry == SymmetryBreaking::d4_unit_square ? "d4" : "none") << "\"" + << ",\"pruning\":\"" + << (pruning == Pruning::valley_capacity ? "valley-capacity" : "none") + << "\"" << ",\"solved\":" << boolean(!timed.result.squares().empty()) << ",\"valid\":" << boolean(validation_ok) << ",\"timing_seconds\":{\"solve\":" << timed.search_seconds @@ -177,6 +191,10 @@ int main(int argc, char **argv) { << ",\"backtracks\":" << counters.backtracks << ",\"prune_checks\":" << counters.prune_checks << ",\"prune_hits\":" << counters.prune_hits + << ",\"valley_capacity_checks\":" + << counters.valley_capacity_checks + << ",\"valley_capacity_prunes\":" + << counters.valley_capacity_prunes << ",\"generated_tasks\":" << counters.generated_tasks << ",\"completed_tasks\":" << counters.completed_tasks << "}}\n"; return validation_ok ? 0 : 1; diff --git a/benchmarks/run.py b/benchmarks/run.py index 6e1e38e..c2dd5eb 100755 --- a/benchmarks/run.py +++ b/benchmarks/run.py @@ -80,7 +80,7 @@ def environment(binary): def run_once( - binary, order, mode, search_policy, search_route, symmetry, timeout + binary, order, mode, search_policy, search_route, symmetry, pruning, timeout ): started = time.monotonic() try: @@ -92,6 +92,7 @@ def run_once( search_policy, search_route, symmetry, + pruning, ], check=False, capture_output=True, @@ -237,6 +238,11 @@ def main(): action="store_true", help="disable unique-unit-square D4 symmetry breaking", ) + parser.add_argument( + "--no-pruning", + action="store_true", + help="disable valley-capacity pruning", + ) parser.add_argument( "--measure-overhead", action="store_true", @@ -269,6 +275,7 @@ def main(): args.search_policy, "direct" if args.direct_search else "public", "none" if args.no_symmetry else "d4", + "none" if args.no_pruning else "valley-capacity", args.timeout, ) for trial in range(args.repetitions): @@ -281,6 +288,7 @@ def main(): args.search_policy, "direct" if args.direct_search else "public", "none" if args.no_symmetry else "d4", + "none" if args.no_pruning else "valley-capacity", args.timeout, ) ) @@ -310,6 +318,7 @@ def main(): "candidate_order": args.search_policy, "search_route": "direct" if args.direct_search else "public", "symmetry_breaking": "none" if args.no_symmetry else "d4", + "pruning": "none" if args.no_pruning else "valley-capacity", "stdout": "captured; rendered grid suppressed by probe", }, "cases": cases, diff --git a/main.cc b/main.cc index d206f83..c658e62 100644 --- a/main.cc +++ b/main.cc @@ -150,6 +150,8 @@ namespace { size_t backtracks = 0; size_t prune_checks = 0; size_t prune_hits = 0; + size_t valley_capacity_checks = 0; + size_t valley_capacity_prunes = 0; size_t generated_tasks = 0; size_t completed_tasks = 0; }; @@ -168,6 +170,12 @@ namespace { d4_unit_square, }; + /** Cheap necessary conditions applied before branching at a search node. */ + enum class Pruning { + disabled, + valley_capacity, + }; + /** 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 @@ -223,7 +231,35 @@ namespace { return best; } - template + /** Return whether the remaining narrow pieces can fill a valley to its rim. + * + * Until the valley reaches the lower of its two neighbouring heights, no + * square wider than the valley can enter it. The area of all remaining + * squares no wider than the valley is therefore an upper bound on the area + * available to fill this isolated strip. This includes the familiar + * width-one and width-two gap checks without special cases. + */ + [[nodiscard]] auto valley_has_sufficient_capacity( + Valley const valley, std::vector const &skyline, + Avail const &available) noexcept -> bool { + auto const end = valley.x + valley.width; + auto const left_height = + valley.x == 0 ? skyline.size() : skyline[valley.x - 1]; + auto const right_height = + end == skyline.size() ? skyline.size() : skyline[end]; + auto const rim_height = std::min(left_height, right_height); + auto const required_area = valley.width * (rim_height - valley.height); + + size_t available_area = 0; + auto const largest = std::min( + valley.width, static_cast(available.size() - 1)); + for (size_t side = 1; side <= largest; ++side) { + available_area += available[side] * side * side; + } + return available_area >= required_area; + } + + template auto search_skyline(size_t const n, size_t const length, SearchPolicy const policy, std::vector &skyline, Avail &available, @@ -239,6 +275,19 @@ namespace { } auto const valley = smallest_valley(skyline); + if constexpr (PruneValleyCapacity) { + if constexpr (Instrument) { + ++counters->prune_checks; + ++counters->valley_capacity_checks; + } + if (!valley_has_sufficient_capacity(valley, skyline, available)) { + if constexpr (Instrument) { + ++counters->prune_hits; + ++counters->valley_capacity_prunes; + } + return false; + } + } auto const largest = std::min({n, valley.width, length - valley.height}); auto try_side = [&](size_t const side) { @@ -271,7 +320,7 @@ namespace { side, valley.height + side); squares.emplace_back(valley.x + valley.height * length, side); - if (search_skyline( + if (search_skyline( n, length, policy, skyline, available, squares, counters)) { return true; } @@ -318,7 +367,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 { @@ -330,7 +379,8 @@ 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)}; @@ -340,12 +390,19 @@ namespace { size_t const n, SearchPolicy const policy = SearchPolicy::ascending, SymmetryBreaking const symmetry = - SymmetryBreaking::d4_unit_square) noexcept + SymmetryBreaking::d4_unit_square, + Pruning const pruning = Pruning::valley_capacity) noexcept -> Results { if (symmetry == SymmetryBreaking::d4_unit_square) { - return search_solution_impl(n, policy, nullptr); + if (pruning == Pruning::valley_capacity) { + return search_solution_impl(n, policy, nullptr); + } + return search_solution_impl(n, policy, nullptr); } - return search_solution_impl(n, policy, nullptr); + if (pruning == Pruning::valley_capacity) { + return search_solution_impl(n, policy, nullptr); + } + return search_solution_impl(n, policy, nullptr); } auto search_solution_instrumented(size_t const n, @@ -353,13 +410,23 @@ namespace { SearchPolicy const policy = SearchPolicy::ascending, SymmetryBreaking const symmetry = - SymmetryBreaking::d4_unit_square) noexcept + SymmetryBreaking::d4_unit_square, + Pruning const pruning = + Pruning::valley_capacity) noexcept -> Results { counters = {}; if (symmetry == SymmetryBreaking::d4_unit_square) { - return search_solution_impl(n, policy, &counters); + if (pruning == Pruning::valley_capacity) { + return search_solution_impl( + n, policy, &counters); + } + return search_solution_impl(n, policy, &counters); } - return search_solution_impl(n, policy, &counters); + if (pruning == Pruning::valley_capacity) { + return search_solution_impl( + n, policy, &counters); + } + return search_solution_impl(n, policy, &counters); } /** Construct an odd-order solution from its even-order predecessor. */ @@ -396,12 +463,13 @@ namespace { size_t const n, SearchPolicy const policy = SearchPolicy::ascending, SymmetryBreaking const symmetry = - SymmetryBreaking::d4_unit_square) noexcept -> Results { + SymmetryBreaking::d4_unit_square, + Pruning const pruning = Pruning::valley_capacity) noexcept -> Results { if (uses_odd_construction(n)) { return construct_odd_solution( - n, search_solution(n - 1, policy, symmetry)); + n, search_solution(n - 1, policy, symmetry, pruning)); } - return search_solution(n, policy, symmetry); + return search_solution(n, policy, symmetry, pruning); } auto find_solution_instrumented(size_t const n, @@ -409,14 +477,17 @@ namespace { SearchPolicy const policy = SearchPolicy::ascending, SymmetryBreaking const symmetry = - SymmetryBreaking::d4_unit_square) noexcept + SymmetryBreaking::d4_unit_square, + Pruning const pruning = + Pruning::valley_capacity) noexcept -> Results { if (uses_odd_construction(n)) { return construct_odd_solution( n, search_solution_instrumented( - n - 1, counters, policy, symmetry)); + n - 1, counters, policy, symmetry, pruning)); } - return search_solution_instrumented(n, counters, policy, symmetry); + return search_solution_instrumented( + n, counters, policy, symmetry, pruning); } } // anon namespace diff --git a/tests/tests.cc b/tests/tests.cc index f57b394..6df20a6 100644 --- a/tests/tests.cc +++ b/tests/tests.cc @@ -367,6 +367,8 @@ namespace { counters.backtracks > 0, "instrumented solver did not count placements/backtracks"); failures += expect(counters.prune_checks >= counters.prune_hits && + counters.valley_capacity_checks >= + counters.valley_capacity_prunes && counters.generated_tasks == 0 && counters.completed_tasks == 0, "search counters are inconsistent"); @@ -427,10 +429,10 @@ namespace { SearchCounters disabled_counters; auto const enabled = search_solution_instrumented( 8, enabled_counters, SearchPolicy::ascending, - SymmetryBreaking::d4_unit_square); + SymmetryBreaking::d4_unit_square, Pruning::disabled); auto const disabled = search_solution_instrumented( 8, disabled_counters, SearchPolicy::ascending, - SymmetryBreaking::disabled); + SymmetryBreaking::disabled, Pruning::disabled); auto enabled_validation = validate(8, enabled); auto disabled_validation = validate(8, disabled); failures += expect( @@ -511,6 +513,75 @@ namespace { return failures; } + auto test_valley_capacity_pruning() -> int { + int failures = 0; + Avail available(5); + + available[1] = 1; + failures += expect( + !valley_has_sufficient_capacity( + {1, 0, 1}, std::vector{4, 0, 4}, available), + "width-one gap accepted insufficient unit-square capacity"); + + available = {}; + available.resize(5); + available[2] = 1; + failures += expect( + !valley_has_sufficient_capacity( + {1, 0, 2}, std::vector{4, 0, 0, 4}, available), + "width-two gap accepted insufficient narrow-piece area"); + available[2] = 2; + failures += expect( + valley_has_sufficient_capacity( + {1, 0, 2}, std::vector{4, 0, 0, 4}, available), + "width-two gap rejected exactly sufficient narrow-piece area"); + + available = {}; + available.resize(5); + available[1] = 1; + available[2] = 1; + failures += expect( + !valley_has_sufficient_capacity( + {1, 2, 2}, std::vector{5, 2, 2, 5}, available), + "general valley accepted less area than required to reach its rim"); + available[2] = 2; + failures += expect( + valley_has_sufficient_capacity( + {1, 2, 2}, std::vector{5, 2, 2, 5}, available), + "general valley rejected sufficient fitting-square area"); + + for (auto const order: std::array{1, 2, 3, 4, 5}) { + SearchCounters pruned_counters; + SearchCounters unpruned_counters; + auto const pruned = search_solution_instrumented( + order, pruned_counters, SearchPolicy::ascending, + SymmetryBreaking::disabled, Pruning::valley_capacity); + auto const unpruned = search_solution_instrumented( + order, unpruned_counters, SearchPolicy::ascending, + SymmetryBreaking::disabled, Pruning::disabled); + failures += expect( + pruned.squares().empty() == unpruned.squares().empty(), + "valley pruning changed exhaustive order-" + + std::to_string(order) + " feasibility"); + failures += expect( + pruned_counters.search_nodes <= unpruned_counters.search_nodes, + "valley pruning enlarged the exhaustive order-" + + std::to_string(order) + " search"); + } + + SearchCounters counters; + static_cast(search_solution_instrumented( + 5, counters, SearchPolicy::ascending, SymmetryBreaking::disabled, + Pruning::valley_capacity)); + failures += expect( + counters.valley_capacity_checks > 0 && + counters.valley_capacity_prunes > 0 && + counters.prune_checks == counters.valley_capacity_checks && + counters.prune_hits == counters.valley_capacity_prunes, + "valley pruning instrumentation did not count checks and hits"); + return failures; + } + auto test_solver_completion() -> int { int failures = 0; for (auto const order: std::array{1, 8}) { @@ -559,6 +630,9 @@ int main(int argc, char **argv) { if (test == "d4-symmetry") { return test_d4_symmetry(); } + if (test == "valley-capacity") { + return test_valley_capacity_pruning(); + } std::cerr << "unknown test: " << test << '\n'; return 2; } -- 2.54.0