solver: prune unplaceable large squares #27

Merged
mcp merged 1 commits from codex/issue-15-large-square-pruning into main 2026-07-31 08:26:26 +01:00
6 changed files with 264 additions and 43 deletions
Showing only changes of commit 220cec06a9 - Show all commits
+52 -2
View File
@@ -21,8 +21,9 @@ and `--candidate-order`. The choices are `ascending`, `descending`, and
The production default also removes equivalent D4 board orientations by The production default also removes equivalent D4 board orientations by
constraining the unique unit square. Pass `--no-symmetry` to obtain an constraining the unique unit square. Pass `--no-symmetry` to obtain an
otherwise identical unconstrained baseline. otherwise identical unconstrained baseline.
The production default also applies the valley-capacity rule described below. The production default also applies the pruning rules described below.
Pass `--no-pruning` to obtain an otherwise identical unpruned search. 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 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: 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%, per-node cost also reduced median counted solve time by 10.3% and 9.9%,
respectively, so it remains enabled by default. 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 ## D4 board symmetry
Every solution contains exactly one 1-by-1 square. Rotations and reflections Every solution contains exactly one 1-by-1 square. Rotations and reflections
+1
View File
@@ -27,6 +27,7 @@ if(BUILD_TESTING)
add_test(NAME skyline-search COMMAND partridge_tests skyline-search) add_test(NAME skyline-search COMMAND partridge_tests skyline-search)
add_test(NAME d4-symmetry COMMAND partridge_tests d4-symmetry) add_test(NAME d4-symmetry COMMAND partridge_tests d4-symmetry)
add_test(NAME valley-capacity COMMAND partridge_tests valley-capacity) 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) find_package(Python3 COMPONENTS Interpreter)
if(Python3_Interpreter_FOUND) if(Python3_Interpreter_FOUND)
add_test(NAME benchmark-format add_test(NAME benchmark-format
+27 -6
View File
@@ -92,13 +92,24 @@ namespace {
} }
auto boolean(bool value) -> char const * { return value ? "true" : "false"; } 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) { int main(int argc, char **argv) {
if (argc < 3 || argc > 7) { if (argc < 3 || argc > 7) {
std::cerr << "usage: partridge_benchmark ORDER counters|plain " std::cerr << "usage: partridge_benchmark ORDER counters|plain "
"[ascending|descending|best-fit] [public|direct] " "[ascending|descending|best-fit] [public|direct] "
"[d4|none] [valley-capacity|none]\n"; "[d4|none] [all|valley-capacity|large-square|none]\n";
return 2; return 2;
} }
auto const order = static_cast<std::uint64_t>(std::strtoull(argv[1], nullptr, 10)); auto const order = static_cast<std::uint64_t>(std::strtoull(argv[1], nullptr, 10));
@@ -136,12 +147,19 @@ int main(int argc, char **argv) {
return 2; return 2;
} }
auto const pruning = auto const pruning =
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 ? Pruning::valley_capacity
: std::string_view(argv[6]) == "large-square"
? Pruning::large_square
: Pruning::disabled; : Pruning::disabled;
if (argc == 7 && std::string_view(argv[6]) != "valley-capacity" && 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::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; return 2;
} }
@@ -177,8 +195,7 @@ int main(int argc, char **argv) {
<< (symmetry == SymmetryBreaking::d4_unit_square ? "d4" : "none") << (symmetry == SymmetryBreaking::d4_unit_square ? "d4" : "none")
<< "\"" << "\""
<< ",\"pruning\":\"" << ",\"pruning\":\""
<< (pruning == Pruning::valley_capacity ? "valley-capacity" : "none") << pruning_name(pruning) << "\""
<< "\""
<< ",\"solved\":" << boolean(!timed.result.squares().empty()) << ",\"solved\":" << boolean(!timed.result.squares().empty())
<< ",\"valid\":" << boolean(validation_ok) << ",\"valid\":" << boolean(validation_ok)
<< ",\"timing_seconds\":{\"solve\":" << timed.search_seconds << ",\"timing_seconds\":{\"solve\":" << timed.search_seconds
@@ -195,6 +212,10 @@ int main(int argc, char **argv) {
<< counters.valley_capacity_checks << counters.valley_capacity_checks
<< ",\"valley_capacity_prunes\":" << ",\"valley_capacity_prunes\":"
<< counters.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 << ",\"generated_tasks\":" << counters.generated_tasks
<< ",\"completed_tasks\":" << counters.completed_tasks << "}}\n"; << ",\"completed_tasks\":" << counters.completed_tasks << "}}\n";
return validation_ok ? 0 : 1; return validation_ok ? 0 : 1;
+10 -4
View File
@@ -238,10 +238,16 @@ def main():
action="store_true", action="store_true",
help="disable unique-unit-square D4 symmetry breaking", 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( parser.add_argument(
"--no-pruning", "--no-pruning",
action="store_true", action="store_true",
help="disable valley-capacity pruning", help="disable all pruning (compatibility alias for --pruning none)",
) )
parser.add_argument( parser.add_argument(
"--measure-overhead", "--measure-overhead",
@@ -275,7 +281,7 @@ def main():
args.search_policy, args.search_policy,
"direct" if args.direct_search else "public", "direct" if args.direct_search else "public",
"none" if args.no_symmetry else "d4", "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, args.timeout,
) )
for trial in range(args.repetitions): for trial in range(args.repetitions):
@@ -288,7 +294,7 @@ def main():
args.search_policy, args.search_policy,
"direct" if args.direct_search else "public", "direct" if args.direct_search else "public",
"none" if args.no_symmetry else "d4", "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, args.timeout,
) )
) )
@@ -318,7 +324,7 @@ def main():
"candidate_order": args.search_policy, "candidate_order": args.search_policy,
"search_route": "direct" if args.direct_search else "public", "search_route": "direct" if args.direct_search else "public",
"symmetry_breaking": "none" if args.no_symmetry else "d4", "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", "stdout": "captured; rendered grid suppressed by probe",
}, },
"cases": cases, "cases": cases,
+101 -28
View File
@@ -152,6 +152,8 @@ namespace {
size_t prune_hits = 0; size_t prune_hits = 0;
size_t valley_capacity_checks = 0; size_t valley_capacity_checks = 0;
size_t valley_capacity_prunes = 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 generated_tasks = 0;
size_t completed_tasks = 0; size_t completed_tasks = 0;
}; };
@@ -172,8 +174,10 @@ namespace {
/** Cheap necessary conditions applied before branching at a search node. */ /** Cheap necessary conditions applied before branching at a search node. */
enum class Pruning { enum class Pruning {
disabled, disabled = 0,
valley_capacity, valley_capacity = 1,
large_square = 2,
all = 3,
}; };
/** Return whether a cell is the canonical representative of its D4 orbit. /** Return whether a cell is the canonical representative of its D4 orbit.
@@ -259,7 +263,46 @@ namespace {
return available_area >= required_area; return available_area >= required_area;
} }
template<bool Instrument, bool BreakD4Symmetry, bool PruneValleyCapacity> /** 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<size_t> 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<size_t> 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<bool Instrument, bool BreakD4Symmetry, bool PruneValleyCapacity,
bool PruneLargeSquare>
auto search_skyline(size_t const n, size_t const length, auto search_skyline(size_t const n, size_t const length,
SearchPolicy const policy, SearchPolicy const policy,
std::vector<size_t> &skyline, Avail &available, std::vector<size_t> &skyline, Avail &available,
@@ -288,6 +331,19 @@ namespace {
return false; 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 = auto const largest =
std::min({n, valley.width, length - valley.height}); std::min({n, valley.width, length - valley.height});
auto try_side = [&](size_t const side) { auto try_side = [&](size_t const side) {
@@ -320,7 +376,8 @@ namespace {
side, valley.height + side); side, valley.height + side);
squares.emplace_back(valley.x + valley.height * length, side); squares.emplace_back(valley.x + valley.height * length, side);
if (search_skyline<Instrument, BreakD4Symmetry, PruneValleyCapacity>( if (search_skyline<Instrument, BreakD4Symmetry, PruneValleyCapacity,
PruneLargeSquare>(
n, length, policy, skyline, available, squares, counters)) { n, length, policy, skyline, available, squares, counters)) {
return true; return true;
} }
@@ -367,7 +424,8 @@ namespace {
* candidates and updating up to n columns for each costs O(n^2), for * 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. * O(board width + n^2) local work per node and the same total state.
*/ */
template<bool Instrument, bool BreakD4Symmetry, bool PruneValleyCapacity> template<bool Instrument, bool BreakD4Symmetry, bool PruneValleyCapacity,
bool PruneLargeSquare>
auto search_solution_impl(size_t const n, SearchPolicy const policy, auto search_solution_impl(size_t const n, SearchPolicy const policy,
SearchCounters *const counters) noexcept SearchCounters *const counters) noexcept
-> Results { -> Results {
@@ -380,29 +438,50 @@ namespace {
std::vector<Square> squares; std::vector<Square> squares;
squares.reserve(length); squares.reserve(length);
static_cast<void>( static_cast<void>(
search_skyline<Instrument, BreakD4Symmetry, PruneValleyCapacity>( search_skyline<Instrument, BreakD4Symmetry, PruneValleyCapacity,
PruneLargeSquare>(
n, length, policy, skyline, available, squares, counters)); n, length, policy, skyline, available, squares, counters));
return {length, std::move(squares)}; return {length, std::move(squares)};
} }
template<bool Instrument, bool BreakD4Symmetry>
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<Instrument, BreakD4Symmetry, true, true>(
n, policy, counters);
case Pruning::valley_capacity:
return search_solution_impl<Instrument, BreakD4Symmetry, true, false>(
n, policy, counters);
case Pruning::large_square:
return search_solution_impl<Instrument, BreakD4Symmetry, false, true>(
n, policy, counters);
case Pruning::disabled:
return search_solution_impl<Instrument, BreakD4Symmetry, false, false>(
n, policy, counters);
}
assert(false);
return search_solution_impl<Instrument, BreakD4Symmetry, false, false>(
n, policy, counters);
}
auto search_solution( auto search_solution(
size_t const n, size_t const n,
SearchPolicy const policy = SearchPolicy::ascending, SearchPolicy const policy = SearchPolicy::ascending,
SymmetryBreaking const symmetry = SymmetryBreaking const symmetry =
SymmetryBreaking::d4_unit_square, SymmetryBreaking::d4_unit_square,
Pruning const pruning = Pruning::valley_capacity) noexcept Pruning const pruning = Pruning::all) noexcept
-> Results { -> Results {
if (symmetry == SymmetryBreaking::d4_unit_square) { if (symmetry == SymmetryBreaking::d4_unit_square) {
if (pruning == Pruning::valley_capacity) { return search_solution_dispatch<false, true>(
return search_solution_impl<false, true, true>(n, policy, nullptr); n, policy, pruning, nullptr);
} }
return search_solution_impl<false, true, false>(n, policy, nullptr); return search_solution_dispatch<false, false>(
} n, policy, pruning, nullptr);
if (pruning == Pruning::valley_capacity) {
return search_solution_impl<false, false, true>(n, policy, nullptr);
}
return search_solution_impl<false, false, false>(n, policy, nullptr);
} }
auto search_solution_instrumented(size_t const n, auto search_solution_instrumented(size_t const n,
@@ -412,21 +491,15 @@ namespace {
SymmetryBreaking const symmetry = SymmetryBreaking const symmetry =
SymmetryBreaking::d4_unit_square, SymmetryBreaking::d4_unit_square,
Pruning const pruning = Pruning const pruning =
Pruning::valley_capacity) noexcept Pruning::all) noexcept
-> Results { -> Results {
counters = {}; counters = {};
if (symmetry == SymmetryBreaking::d4_unit_square) { if (symmetry == SymmetryBreaking::d4_unit_square) {
if (pruning == Pruning::valley_capacity) { return search_solution_dispatch<true, true>(
return search_solution_impl<true, true, true>( n, policy, pruning, &counters);
n, policy, &counters);
} }
return search_solution_impl<true, true, false>(n, policy, &counters); return search_solution_dispatch<true, false>(
} n, policy, pruning, &counters);
if (pruning == Pruning::valley_capacity) {
return search_solution_impl<true, false, true>(
n, policy, &counters);
}
return search_solution_impl<true, false, false>(n, policy, &counters);
} }
/** Construct an odd-order solution from its even-order predecessor. */ /** Construct an odd-order solution from its even-order predecessor. */
@@ -464,7 +537,7 @@ namespace {
SearchPolicy const policy = SearchPolicy::ascending, SearchPolicy const policy = SearchPolicy::ascending,
SymmetryBreaking const symmetry = SymmetryBreaking const symmetry =
SymmetryBreaking::d4_unit_square, SymmetryBreaking::d4_unit_square,
Pruning const pruning = Pruning::valley_capacity) noexcept -> Results { Pruning const pruning = Pruning::all) noexcept -> Results {
if (uses_odd_construction(n)) { if (uses_odd_construction(n)) {
return construct_odd_solution( return construct_odd_solution(
n, search_solution(n - 1, policy, symmetry, pruning)); n, search_solution(n - 1, policy, symmetry, pruning));
@@ -479,7 +552,7 @@ namespace {
SymmetryBreaking const symmetry = SymmetryBreaking const symmetry =
SymmetryBreaking::d4_unit_square, SymmetryBreaking::d4_unit_square,
Pruning const pruning = Pruning const pruning =
Pruning::valley_capacity) noexcept Pruning::all) noexcept
-> Results { -> Results {
if (uses_odd_construction(n)) { if (uses_odd_construction(n)) {
return construct_odd_solution( return construct_odd_solution(
+70
View File
@@ -369,6 +369,8 @@ namespace {
failures += expect(counters.prune_checks >= counters.prune_hits && failures += expect(counters.prune_checks >= counters.prune_hits &&
counters.valley_capacity_checks >= counters.valley_capacity_checks >=
counters.valley_capacity_prunes && counters.valley_capacity_prunes &&
counters.large_square_checks >=
counters.large_square_prunes &&
counters.generated_tasks == 0 && counters.generated_tasks == 0 &&
counters.completed_tasks == 0, counters.completed_tasks == 0,
"search counters are inconsistent"); "search counters are inconsistent");
@@ -582,6 +584,71 @@ namespace {
return failures; return failures;
} }
auto test_large_square_pruning() -> int {
int failures = 0;
auto const fragmented =
std::vector<std::uint64_t>{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<std::uint64_t>{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<std::uint64_t, 5>{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<void>(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 { auto test_solver_completion() -> int {
int failures = 0; int failures = 0;
for (auto const order: std::array<std::uint64_t, 2>{1, 8}) { for (auto const order: std::array<std::uint64_t, 2>{1, 8}) {
@@ -633,6 +700,9 @@ int main(int argc, char **argv) {
if (test == "valley-capacity") { if (test == "valley-capacity") {
return test_valley_capacity_pruning(); return test_valley_capacity_pruning();
} }
if (test == "large-square") {
return test_large_square_pruning();
}
std::cerr << "unknown test: " << test << '\n'; std::cerr << "unknown test: " << test << '\n';
return 2; return 2;
} }