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
This commit was merged in pull request #26.
This commit is contained in:
Codex instance
2026-07-31 08:11:30 +01:00
parent f37e08768d
commit e27427d231
6 changed files with 234 additions and 26 deletions
+37 -2
View File
@@ -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
+1
View File
@@ -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
+23 -5
View File
@@ -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::uint64_t>(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;
+10 -1
View File
@@ -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,
+87 -16
View File
@@ -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<bool Instrument, bool BreakD4Symmetry>
/** 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<size_t> 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<size_t>(available.size() - 1));
for (size_t side = 1; side <= largest; ++side) {
available_area += available[side] * side * side;
}
return available_area >= required_area;
}
template<bool Instrument, bool BreakD4Symmetry, bool PruneValleyCapacity>
auto search_skyline(size_t const n, size_t const length,
SearchPolicy const policy,
std::vector<size_t> &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<Instrument, BreakD4Symmetry>(
if (search_skyline<Instrument, BreakD4Symmetry, PruneValleyCapacity>(
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<bool Instrument, bool BreakD4Symmetry>
template<bool Instrument, bool BreakD4Symmetry, bool PruneValleyCapacity>
auto search_solution_impl(size_t const n, SearchPolicy const policy,
SearchCounters *const counters) noexcept
-> Results {
@@ -330,7 +379,8 @@ namespace {
}
std::vector<Square> squares;
squares.reserve(length);
static_cast<void>(search_skyline<Instrument, BreakD4Symmetry>(
static_cast<void>(
search_skyline<Instrument, BreakD4Symmetry, PruneValleyCapacity>(
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<false, true>(n, policy, nullptr);
if (pruning == Pruning::valley_capacity) {
return search_solution_impl<false, true, true>(n, policy, nullptr);
}
return search_solution_impl<false, true, false>(n, policy, nullptr);
}
return search_solution_impl<false, false>(n, policy, 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,
@@ -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<true, true>(n, policy, &counters);
if (pruning == Pruning::valley_capacity) {
return search_solution_impl<true, true, true>(
n, policy, &counters);
}
return search_solution_impl<true, true, false>(n, policy, &counters);
}
return search_solution_impl<true, false>(n, policy, &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. */
@@ -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
+76 -2
View File
@@ -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<std::uint64_t>{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<std::uint64_t>{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<std::uint64_t>{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<std::uint64_t>{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<std::uint64_t>{5, 2, 2, 5}, available),
"general valley rejected sufficient fitting-square area");
for (auto const order: std::array<std::uint64_t, 5>{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<void>(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<std::uint64_t, 2>{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;
}