diff --git a/BENCHMARKING.md b/BENCHMARKING.md index 9891770..940e351 100644 --- a/BENCHMARKING.md +++ b/BENCHMARKING.md @@ -21,8 +21,9 @@ 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. +The production default also applies the pruning rules described below. +Use `--pruning valley-capacity`, `--pruning large-square`, or +`--no-pruning` to measure each rule alone or obtain an 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: @@ -107,6 +108,55 @@ 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. +## Remaining-large-square pruning + +For a remaining side `k`, a skyline can contain an empty `k`-by-`k` box only +if it has `k` consecutive columns whose filled heights are no greater than the +board height minus `k`. A single linear scan tracks qualifying consecutive +columns. If no such run exists, the square cannot be placed and the state is +infeasible even when its total empty area is sufficient. + +Empty-box feasibility is monotonic in the side length: a box which fits the +largest remaining square also fits every smaller remaining size. The solver +therefore invokes only one `O(board width)` scan per surviving node, after the +cheaper valley-capacity check. It does not attempt an unproven multiplicity +bound. `--pruning large-square` measures the rule independently; +`--pruning valley-capacity` provides the production baseline without it. +Instrumented output records `large_square_checks` and +`large_square_prunes`. + +Measurements used the issue #15 working tree based on commit `e27427d`, Apple +Clang 21.0.0, `-O3 -DNDEBUG`, macOS arm64, one worker, one warm-up, and seven +measured repetitions. Counter-free and counted runs were interleaved; the +table reports the counter-free production instantiation. The baseline keeps +valley-capacity pruning enabled, so it isolates the new rule. All results +passed independent validation and counters were stable: + +| Search | Large-square check | Median solve | Nodes | Checks | Prunes | +| --- | --- | ---: | ---: | ---: | ---: | +| Order 7 exhaustive | enabled | 0.946 s | 13,221,239 | 6,163,390 | 227,321 | +| Order 7 exhaustive | disabled | 0.947 s | 13,833,048 | 0 | 0 | +| Order 8 first solution | enabled | 0.195 s | 2,597,678 | 1,063,472 | 38,177 | +| Order 8 first solution | disabled | 0.197 s | 2,724,096 | 0 | 0 | +| Order 9 direct | enabled | 1.225 s | 14,840,146 | 5,592,843 | 54,900 | +| Order 9 direct | disabled | 1.208 s | 14,995,127 | 0 | 0 | + +The rule reduced nodes by 4.4% for order 7, 4.6% for order 8, and 1.0% for +direct order 9. Counter-free median time improved by 0.12% and 0.89% on the +two distinct searches in the public benchmark set. Public order 9 constructs +from the improving order-8 search. Direct order 9, which bypasses that public +route, regressed by 1.39%; it remains documented as a caution for future +policy tuning. + +Checking only every fourth placement depth was also measured. It retained +fewer prunes and was slower than checking every surviving node by 2.2% for +order 7, 2.3% for order 8, and 0.6% for direct order 9, so the periodic +schedule was rejected. Incremental maintenance would need additional +per-size window state and undo logic for an `O(W)` scan whose public net cost +is already recovered; it was not added without evidence that the complexity +would improve elapsed time. The simple every-node scan remains enabled by +default for the measured public benchmark benefit. + ## D4 board symmetry Every solution contains exactly one 1-by-1 square. Rotations and reflections diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b56619..686819b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,7 @@ if(BUILD_TESTING) 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) + add_test(NAME large-square COMMAND partridge_tests large-square) 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 495fd75..b91be83 100644 --- a/benchmarks/benchmark.cc +++ b/benchmarks/benchmark.cc @@ -92,13 +92,24 @@ namespace { } auto boolean(bool value) -> char const * { return value ? "true" : "false"; } + + auto pruning_name(Pruning const pruning) -> char const * { + switch (pruning) { + case Pruning::all: return "all"; + case Pruning::valley_capacity: return "valley-capacity"; + case Pruning::large_square: return "large-square"; + case Pruning::disabled: return "none"; + } + assert(false); + return "none"; + } } int main(int argc, char **argv) { if (argc < 3 || argc > 7) { std::cerr << "usage: partridge_benchmark ORDER counters|plain " "[ascending|descending|best-fit] [public|direct] " - "[d4|none] [valley-capacity|none]\n"; + "[d4|none] [all|valley-capacity|large-square|none]\n"; return 2; } auto const order = static_cast(std::strtoull(argv[1], nullptr, 10)); @@ -136,12 +147,19 @@ int main(int argc, char **argv) { 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" && + argc < 7 || std::string_view(argv[6]) == "all" + ? Pruning::all + : std::string_view(argv[6]) == "valley-capacity" + ? Pruning::valley_capacity + : std::string_view(argv[6]) == "large-square" + ? Pruning::large_square + : Pruning::disabled; + if (argc == 7 && std::string_view(argv[6]) != "all" && + std::string_view(argv[6]) != "valley-capacity" && + std::string_view(argv[6]) != "large-square" && std::string_view(argv[6]) != "none") { - std::cerr << "pruning mode must be valley-capacity or none\n"; + std::cerr << "pruning mode must be all, valley-capacity, " + "large-square, or none\n"; return 2; } @@ -177,8 +195,7 @@ int main(int argc, char **argv) { << (symmetry == SymmetryBreaking::d4_unit_square ? "d4" : "none") << "\"" << ",\"pruning\":\"" - << (pruning == Pruning::valley_capacity ? "valley-capacity" : "none") - << "\"" + << pruning_name(pruning) << "\"" << ",\"solved\":" << boolean(!timed.result.squares().empty()) << ",\"valid\":" << boolean(validation_ok) << ",\"timing_seconds\":{\"solve\":" << timed.search_seconds @@ -195,6 +212,10 @@ int main(int argc, char **argv) { << counters.valley_capacity_checks << ",\"valley_capacity_prunes\":" << counters.valley_capacity_prunes + << ",\"large_square_checks\":" + << counters.large_square_checks + << ",\"large_square_prunes\":" + << counters.large_square_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 c2dd5eb..d0c1aee 100755 --- a/benchmarks/run.py +++ b/benchmarks/run.py @@ -238,10 +238,16 @@ def main(): action="store_true", help="disable unique-unit-square D4 symmetry breaking", ) + parser.add_argument( + "--pruning", + choices=("all", "valley-capacity", "large-square", "none"), + default="all", + help="select independently measurable pruning rules", + ) parser.add_argument( "--no-pruning", action="store_true", - help="disable valley-capacity pruning", + help="disable all pruning (compatibility alias for --pruning none)", ) parser.add_argument( "--measure-overhead", @@ -275,7 +281,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", + "none" if args.no_pruning else args.pruning, args.timeout, ) for trial in range(args.repetitions): @@ -288,7 +294,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", + "none" if args.no_pruning else args.pruning, args.timeout, ) ) @@ -318,7 +324,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", + "pruning": "none" if args.no_pruning else args.pruning, "stdout": "captured; rendered grid suppressed by probe", }, "cases": cases, diff --git a/main.cc b/main.cc index c658e62..09429a9 100644 --- a/main.cc +++ b/main.cc @@ -152,6 +152,8 @@ namespace { size_t prune_hits = 0; size_t valley_capacity_checks = 0; size_t valley_capacity_prunes = 0; + size_t large_square_checks = 0; + size_t large_square_prunes = 0; size_t generated_tasks = 0; size_t completed_tasks = 0; }; @@ -172,8 +174,10 @@ namespace { /** Cheap necessary conditions applied before branching at a search node. */ enum class Pruning { - disabled, - valley_capacity, + disabled = 0, + valley_capacity = 1, + large_square = 2, + all = 3, }; /** Return whether a cell is the canonical representative of its D4 orbit. @@ -259,7 +263,46 @@ namespace { return available_area >= required_area; } - template + /** Return whether the skyline contains an empty box for a square. + * + * A side-k square fits exactly when k consecutive columns have heights no + * greater than board height minus k. Tracking the current qualifying run + * checks this in one pass without allocations. + */ + [[nodiscard]] auto skyline_has_empty_square( + std::vector const &skyline, size_t const side) noexcept -> bool { + assert(side > 0); + assert(side <= skyline.size()); + auto const maximum_height = skyline.size() - side; + size_t run = 0; + for (auto const height: skyline) { + run = height <= maximum_height ? run + 1 : 0; + if (run == side) { + return true; + } + } + return false; + } + + /** Return whether the largest remaining square has any feasible position. + * + * Feasible empty boxes are monotonic in the side length: a box which fits + * the largest remaining square also fits every smaller one. It is therefore + * sufficient to test only the largest size with non-zero multiplicity. + */ + [[nodiscard]] auto remaining_large_square_fits( + std::vector const &skyline, Avail const &available) noexcept + -> bool { + for (auto side = available.size() - 1; side != 0; --side) { + if (available[side] != 0) { + return skyline_has_empty_square(skyline, side); + } + } + return true; + } + + template auto search_skyline(size_t const n, size_t const length, SearchPolicy const policy, std::vector &skyline, Avail &available, @@ -288,6 +331,19 @@ namespace { return false; } } + if constexpr (PruneLargeSquare) { + if constexpr (Instrument) { + ++counters->prune_checks; + ++counters->large_square_checks; + } + if (!remaining_large_square_fits(skyline, available)) { + if constexpr (Instrument) { + ++counters->prune_hits; + ++counters->large_square_prunes; + } + return false; + } + } auto const largest = std::min({n, valley.width, length - valley.height}); auto try_side = [&](size_t const side) { @@ -320,7 +376,8 @@ 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; } @@ -367,7 +424,8 @@ 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 { @@ -380,29 +438,50 @@ namespace { std::vector squares; squares.reserve(length); static_cast( - search_skyline( - n, length, policy, skyline, available, squares, counters)); + search_skyline( + n, length, policy, skyline, available, squares, counters)); return {length, std::move(squares)}; } + template + auto search_solution_dispatch(size_t const n, SearchPolicy const policy, + Pruning const pruning, + SearchCounters *const counters) noexcept + -> Results { + switch (pruning) { + case Pruning::all: + return search_solution_impl( + n, policy, counters); + case Pruning::valley_capacity: + return search_solution_impl( + n, policy, counters); + case Pruning::large_square: + return search_solution_impl( + n, policy, counters); + case Pruning::disabled: + return search_solution_impl( + n, policy, counters); + } + assert(false); + return search_solution_impl( + n, policy, counters); + } + auto search_solution( size_t const n, SearchPolicy const policy = SearchPolicy::ascending, SymmetryBreaking const symmetry = SymmetryBreaking::d4_unit_square, - Pruning const pruning = Pruning::valley_capacity) noexcept + Pruning const pruning = Pruning::all) noexcept -> Results { if (symmetry == SymmetryBreaking::d4_unit_square) { - if (pruning == Pruning::valley_capacity) { - return search_solution_impl(n, policy, nullptr); - } - return search_solution_impl(n, policy, nullptr); + return search_solution_dispatch( + n, policy, pruning, nullptr); } - if (pruning == Pruning::valley_capacity) { - return search_solution_impl(n, policy, nullptr); - } - return search_solution_impl(n, policy, nullptr); + return search_solution_dispatch( + n, policy, pruning, nullptr); } auto search_solution_instrumented(size_t const n, @@ -412,21 +491,15 @@ namespace { SymmetryBreaking const symmetry = SymmetryBreaking::d4_unit_square, Pruning const pruning = - Pruning::valley_capacity) noexcept + Pruning::all) noexcept -> Results { counters = {}; if (symmetry == SymmetryBreaking::d4_unit_square) { - if (pruning == Pruning::valley_capacity) { - return search_solution_impl( - n, policy, &counters); - } - return search_solution_impl(n, policy, &counters); + return search_solution_dispatch( + n, policy, pruning, &counters); } - if (pruning == Pruning::valley_capacity) { - return search_solution_impl( - n, policy, &counters); - } - return search_solution_impl(n, policy, &counters); + return search_solution_dispatch( + n, policy, pruning, &counters); } /** Construct an odd-order solution from its even-order predecessor. */ @@ -464,7 +537,7 @@ namespace { SearchPolicy const policy = SearchPolicy::ascending, SymmetryBreaking const symmetry = SymmetryBreaking::d4_unit_square, - Pruning const pruning = Pruning::valley_capacity) noexcept -> Results { + Pruning const pruning = Pruning::all) noexcept -> Results { if (uses_odd_construction(n)) { return construct_odd_solution( n, search_solution(n - 1, policy, symmetry, pruning)); @@ -479,7 +552,7 @@ namespace { SymmetryBreaking const symmetry = SymmetryBreaking::d4_unit_square, Pruning const pruning = - Pruning::valley_capacity) noexcept + Pruning::all) noexcept -> Results { if (uses_odd_construction(n)) { return construct_odd_solution( diff --git a/tests/tests.cc b/tests/tests.cc index 6df20a6..76038cc 100644 --- a/tests/tests.cc +++ b/tests/tests.cc @@ -369,6 +369,8 @@ namespace { failures += expect(counters.prune_checks >= counters.prune_hits && counters.valley_capacity_checks >= counters.valley_capacity_prunes && + counters.large_square_checks >= + counters.large_square_prunes && counters.generated_tasks == 0 && counters.completed_tasks == 0, "search counters are inconsistent"); @@ -582,6 +584,71 @@ namespace { return failures; } + auto test_large_square_pruning() -> int { + int failures = 0; + auto const fragmented = + std::vector{0, 5, 0, 5, 0, 5}; + failures += expect( + !skyline_has_empty_square(fragmented, 2), + "large-square check accepted a fragmented state without a 2x2 box"); + failures += expect( + skyline_has_empty_square( + std::vector{0, 0, 5, 5, 5, 5}, 2), + "large-square check rejected an available 2x2 box"); + + Avail available(4); + available[1] = 1; + available[2] = 1; + failures += expect( + !remaining_large_square_fits(fragmented, available), + "remaining-square check ignored the impossible largest square"); + available[2] = 0; + failures += expect( + remaining_large_square_fits(fragmented, available), + "remaining-square check did not use geometric size monotonicity"); + + // The fragmented profile has 21 empty cells, more than the area of the + // remaining 2x2 square, but no two adjacent columns have two free rows. + std::uint64_t filled_area = 0; + for (auto const height: fragmented) { + filled_area += height; + } + failures += expect( + filled_area <= 36 - 4, + "fragmented test state does not have sufficient total empty area"); + + for (auto const order: std::array{1, 2, 3, 4, 5}) { + SearchCounters pruned_counters; + SearchCounters baseline_counters; + auto const pruned = search_solution_instrumented( + order, pruned_counters, SearchPolicy::ascending, + SymmetryBreaking::disabled, Pruning::all); + auto const baseline = search_solution_instrumented( + order, baseline_counters, SearchPolicy::ascending, + SymmetryBreaking::disabled, Pruning::valley_capacity); + failures += expect( + pruned.squares().empty() == baseline.squares().empty(), + "large-square pruning changed exhaustive order-" + + std::to_string(order) + " feasibility"); + failures += expect( + pruned_counters.search_nodes <= baseline_counters.search_nodes, + "large-square pruning enlarged the exhaustive order-" + + std::to_string(order) + " search"); + } + + SearchCounters counters; + static_cast(search_solution_instrumented( + 5, counters, SearchPolicy::ascending, SymmetryBreaking::disabled, + Pruning::large_square)); + failures += expect( + counters.large_square_checks > 0 && + counters.large_square_prunes > 0 && + counters.prune_checks == counters.large_square_checks && + counters.prune_hits == counters.large_square_prunes, + "large-square instrumentation did not count checks and prunes"); + return failures; + } + auto test_solver_completion() -> int { int failures = 0; for (auto const order: std::array{1, 8}) { @@ -633,6 +700,9 @@ int main(int argc, char **argv) { if (test == "valley-capacity") { return test_valley_capacity_pruning(); } + if (test == "large-square") { + return test_large_square_pruning(); + } std::cerr << "unknown test: " << test << '\n'; return 2; }