solver: select skyline candidate policy #24

Merged
mcp merged 1 commits from codex/issue-12-candidate-policy into main 2026-07-30 18:08:06 +01:00
6 changed files with 141 additions and 49 deletions
+54 -1
View File
@@ -16,7 +16,8 @@ The default policy is one unrecorded warm-up followed by five repetitions per
case, with a 600-second timeout for each process. Solver stdout is captured; case, with a 600-second timeout for each process. Solver stdout is captured;
the probe renders into an in-memory stream so grids do not perturb terminal I/O. the probe renders into an in-memory stream so grids do not perturb terminal I/O.
Override the policy with `--orders`, `--warmup`, `--repetitions`, `--timeout`, Override the policy with `--orders`, `--warmup`, `--repetitions`, `--timeout`,
and `--candidate-order`. Ascending candidate sizes are the production default. and `--candidate-order`. The choices are `ascending`, `descending`, and
`best-fit`; ascending candidate sizes are the production default.
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:
@@ -114,6 +115,58 @@ search bottleneck, while the existing route-boundary test still verifies that
11 selects construction. Revisit both sizes when order 10 completes within a 11 selects construction. Revisit both sizes when order 10 completes within a
practical test budget. practical test budget.
## Candidate policy selection
Candidate ordering is a deterministic search policy and does not alter the
smallest-valley selection or set of placements tried. `ascending` tries
smaller available squares first and `descending` tries larger ones first.
`best-fit` first tries a square exactly as wide as the selected valley, because
that placement closes the valley without leaving a shelf remainder, then tries
the other sizes in ascending order. If no exact-width square fits, best-fit
and ascending are identical at that node.
The policy comparison used the issue #12 working tree based on commit
`d751d1b`, Apple Clang 21.0.0, `-O3 -DNDEBUG`, macOS arm64, and one worker.
Order 8 used two warm-ups and seven sequential measured repetitions; direct
order 9 used one warm-up and three measured repetitions. All completed
results passed independent validation and node counts were stable:
| Order | Policy | Counted median (range) | Counter-free median | Nodes |
| --- | --- | --- | --- | ---: |
| 8 | ascending | 0.808 s (0.7900.852 s) | 0.794 s | 7,735,369 |
| 8 | descending | 1.310 s (1.2791.449 s) | 1.268 s | 12,186,125 |
| 8 | best-fit | 0.817 s (0.8130.857 s) | 0.823 s | 7,679,349 |
| 9 direct | ascending | 5.522 s (5.4995.830 s) | not measured | 45,840,266 |
| 9 direct | best-fit | 5.651 s (5.5335.820 s) | not measured | 45,746,016 |
The earlier direct-order-9 descending probe exceeded its 15-second limit.
Ascending is retained as the stable single-threaded default because it had the
lowest measured median time to the first solution at both measured solvable
sizes. Best-fit's slightly smaller trees did not compensate for its policy
checks, while descending was substantially worse. Exhaustive infeasible
order-5 tests visit the same number of nodes under all three policies, which
checks that ordering does not affect completeness.
One policy therefore applies to the currently measured sizes 8 and 9. This
does not establish that ascending is optimal for order 10: a bounded best-fit
order-9 comparison changed the search tree by only 0.2%, so there was no
evidence that repeating the known long order-10/11 search would be useful.
Keep 10 and 11 as opt-in benchmark cases. Public order 11 is particularly
important to interpret correctly: it constructs from an order-10 search, so it
does not independently measure an odd-order candidate policy.
A worker portfolio was considered but not added. Running identical policies
duplicates the same deterministic traversal. Pairing ascending with best-fit
adds little diversity on the measured trees, and pairing ascending with
descending dedicates a worker to the consistently slower policy. Splitting a
shared frontier could avoid duplicated prefixes, but that is the parallel
frontier work tracked separately in issue #11. Seeded randomized ordering was
also rejected for now: the deterministic alternatives already select a clear
default, and there is no measurement showing that seed distributions would
improve time to first solution. The benchmark schema retains its nullable
seed field so a future evidence-backed randomized policy can report
reproducible runs without changing the format.
## Post-correctness baseline ## Post-correctness baseline
This framework starts from commit `ce39d0a` after the rendering assertion fix This framework starts from commit `ce39d0a` after the rendering assertion fix
+12 -8
View File
@@ -12,14 +12,18 @@ ctest --test-dir build --output-on-failure
The tests independently check board dimensions, square multiplicities, bounds, The tests independently check board dimensions, square multiplicities, bounds,
overlap, and complete coverage. They cover small unsatisfiable solver inputs, a overlap, and complete coverage. They cover small unsatisfiable solver inputs, a
known order-8 solution, invalid placement diagnostics, and construction of an known order-8 solution, invalid placement diagnostics, and construction of an
order-9 solution from the order-8 fixture. The routed order-9 solver test order-9 solution from the order-8 fixture. The routed best-fit order-9 solver
checks that its search counters exactly match order 8. The skyline tests check test checks that its search counters exactly match order 8. The skyline tests
smallest-width valley selection and deterministic tie-breaking, validate an check smallest-width valley selection and deterministic tie-breaking, validate
order-8 result with descending candidates, and independently validate a direct an order-8 result with descending candidates, and independently validate a
order-9 search with ascending candidates. That direct test is deliberately direct order-9 search with ascending candidates. An
separate from the public order-9 route, which uses even-predecessor exhaustive infeasible order also checks that all three policies visit the same
construction. The rendering test also formats the known order-8 solution and search space. The direct order-9 test is deliberately separate from the
checks the resulting grid dimensions and coverage. 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
selects odd construction. The rendering test also formats the known order-8
solution and checks the resulting grid dimensions and coverage.
When Python is available, `reference-support` also tests the dependency-free When Python is available, `reference-support` also tests the dependency-free
placement JSON validator. If the optional OR-Tools package is present, it placement JSON validator. If the optional OR-Tools package is present, it
+16 -13
View File
@@ -27,7 +27,7 @@ namespace {
}; };
auto solve(std::uint64_t order, bool instrument, auto solve(std::uint64_t order, bool instrument,
CandidateOrder candidate_order, bool direct_search, SearchPolicy policy, bool direct_search,
SearchCounters &counters) SearchCounters &counters)
-> TimedSolution { -> TimedSolution {
auto const predecessor_order = auto const predecessor_order =
@@ -36,8 +36,8 @@ namespace {
auto predecessor = auto predecessor =
instrument instrument
? search_solution_instrumented(predecessor_order, counters, ? search_solution_instrumented(predecessor_order, counters,
candidate_order) policy)
: search_solution(predecessor_order, candidate_order); : search_solution(predecessor_order, policy);
auto const search_end = Clock::now(); auto const search_end = Clock::now();
auto const construction_begin = Clock::now(); auto const construction_begin = Clock::now();
@@ -95,7 +95,7 @@ namespace {
int main(int argc, char **argv) { int main(int argc, char **argv) {
if (argc < 3 || argc > 5) { if (argc < 3 || argc > 5) {
std::cerr << "usage: partridge_benchmark ORDER counters|plain " std::cerr << "usage: partridge_benchmark ORDER counters|plain "
"[ascending|descending] [public|direct]\n"; "[ascending|descending|best-fit] [public|direct]\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));
@@ -104,13 +104,16 @@ int main(int argc, char **argv) {
std::cerr << "counter mode must be counters or plain\n"; std::cerr << "counter mode must be counters or plain\n";
return 2; return 2;
} }
auto const candidate_order = auto const policy =
argc < 4 || std::string_view(argv[3]) == "ascending" argc < 4 || std::string_view(argv[3]) == "ascending"
? CandidateOrder::ascending ? SearchPolicy::ascending
: CandidateOrder::descending; : std::string_view(argv[3]) == "descending"
? SearchPolicy::descending
: SearchPolicy::best_fit;
if (argc >= 4 && std::string_view(argv[3]) != "ascending" && if (argc >= 4 && std::string_view(argv[3]) != "ascending" &&
std::string_view(argv[3]) != "descending") { std::string_view(argv[3]) != "descending" &&
std::cerr << "candidate order must be ascending or descending\n"; std::string_view(argv[3]) != "best-fit") {
std::cerr << "search policy must be ascending, descending, or best-fit\n";
return 2; return 2;
} }
auto const direct_search = auto const direct_search =
@@ -122,8 +125,7 @@ int main(int argc, char **argv) {
} }
SearchCounters counters; SearchCounters counters;
auto timed = auto timed = solve(order, instrument, policy, direct_search, counters);
solve(order, instrument, candidate_order, direct_search, counters);
auto const validation_begin = Clock::now(); auto const validation_begin = Clock::now();
auto const validation_ok = valid(order, timed.result); auto const validation_ok = valid(order, timed.result);
@@ -142,8 +144,9 @@ int main(int argc, char **argv) {
<< ",\"order\":" << order << ",\"order\":" << order
<< ",\"instrumented\":" << boolean(instrument) << ",\"instrumented\":" << boolean(instrument)
<< ",\"candidate_order\":\"" << ",\"candidate_order\":\""
<< (candidate_order == CandidateOrder::ascending ? "ascending" << (policy == SearchPolicy::ascending
: "descending") ? "ascending"
: policy == SearchPolicy::descending ? "descending" : "best-fit")
<< "\"" << "\""
<< ",\"search_route\":\"" << ",\"search_route\":\""
<< (direct_search ? "direct" : "public") << "\"" << (direct_search ? "direct" : "public") << "\""
+7 -6
View File
@@ -79,11 +79,11 @@ def environment(binary):
} }
def run_once(binary, order, mode, candidate_order, search_route, timeout): def run_once(binary, order, mode, search_policy, search_route, timeout):
started = time.monotonic() started = time.monotonic()
try: try:
process = subprocess.run( process = subprocess.run(
[str(binary), str(order), mode, candidate_order, search_route], [str(binary), str(order), mode, search_policy, search_route],
check=False, check=False,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -218,7 +218,8 @@ def main():
parser.add_argument("--timeout", type=float, default=600.0) parser.add_argument("--timeout", type=float, default=600.0)
parser.add_argument( parser.add_argument(
"--candidate-order", "--candidate-order",
choices=("ascending", "descending"), dest="search_policy",
choices=("ascending", "descending", "best-fit"),
default="ascending", default="ascending",
) )
parser.add_argument("--direct-search", action="store_true") parser.add_argument("--direct-search", action="store_true")
@@ -251,7 +252,7 @@ def main():
args.binary, args.binary,
order, order,
mode, mode,
args.candidate_order, args.search_policy,
"direct" if args.direct_search else "public", "direct" if args.direct_search else "public",
args.timeout, args.timeout,
) )
@@ -262,7 +263,7 @@ def main():
args.binary, args.binary,
order, order,
mode, mode,
args.candidate_order, args.search_policy,
"direct" if args.direct_search else "public", "direct" if args.direct_search else "public",
args.timeout, args.timeout,
) )
@@ -290,7 +291,7 @@ def main():
"warmup_runs": args.warmup, "warmup_runs": args.warmup,
"measured_repetitions": args.repetitions, "measured_repetitions": args.repetitions,
"per_run_timeout_seconds": args.timeout, "per_run_timeout_seconds": args.timeout,
"candidate_order": args.candidate_order, "candidate_order": args.search_policy,
"search_route": "direct" if args.direct_search else "public", "search_route": "direct" if args.direct_search else "public",
"stdout": "captured; rendered grid suppressed by probe", "stdout": "captured; rendered grid suppressed by probe",
}, },
+32 -17
View File
@@ -154,9 +154,12 @@ namespace {
size_t completed_tasks = 0; size_t completed_tasks = 0;
}; };
enum class CandidateOrder { /** Deterministic order in which a skyline node tries fitting squares. */
enum class SearchPolicy {
ascending, ascending,
descending, descending,
/** Close the selected valley when possible, then try smaller sizes first. */
best_fit,
}; };
/** A maximal level skyline segment which is lower than its neighbours. */ /** A maximal level skyline segment which is lower than its neighbours. */
@@ -199,7 +202,7 @@ namespace {
template<bool Instrument> template<bool Instrument>
auto search_skyline(size_t const n, size_t const length, auto search_skyline(size_t const n, size_t const length,
CandidateOrder const candidate_order, SearchPolicy const policy,
std::vector<size_t> &skyline, Avail &available, std::vector<size_t> &skyline, Avail &available,
std::vector<Square> &squares, std::vector<Square> &squares,
SearchCounters *const counters) noexcept -> bool { SearchCounters *const counters) noexcept -> bool {
@@ -231,7 +234,7 @@ 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>(n, length, candidate_order, skyline, if (search_skyline<Instrument>(n, length, policy, skyline,
available, squares, counters)) { available, squares, counters)) {
return true; return true;
} }
@@ -246,8 +249,15 @@ namespace {
return false; return false;
}; };
if (candidate_order == CandidateOrder::ascending) { if (policy == SearchPolicy::best_fit && largest == valley.width &&
try_side(largest)) {
return true;
}
if (policy != SearchPolicy::descending) {
for (size_t side = 1; side <= largest; ++side) { for (size_t side = 1; side <= largest; ++side) {
if (policy == SearchPolicy::best_fit && side == valley.width) {
continue;
}
if (try_side(side)) { if (try_side(side)) {
return true; return true;
} }
@@ -272,7 +282,7 @@ namespace {
* 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> template<bool Instrument>
auto search_solution_impl(size_t const n, CandidateOrder const candidate_order, auto search_solution_impl(size_t const n, SearchPolicy const policy,
SearchCounters *const counters) noexcept SearchCounters *const counters) noexcept
-> Results { -> Results {
auto const length = triangle_num(n); auto const length = triangle_num(n);
@@ -284,25 +294,25 @@ namespace {
std::vector<Square> squares; std::vector<Square> squares;
squares.reserve(length); squares.reserve(length);
static_cast<void>(search_skyline<Instrument>( static_cast<void>(search_skyline<Instrument>(
n, length, candidate_order, skyline, available, squares, counters)); n, length, policy, skyline, available, squares, counters));
return {length, std::move(squares)}; return {length, std::move(squares)};
} }
auto search_solution( auto search_solution(
size_t const n, size_t const n,
CandidateOrder const candidate_order = CandidateOrder::ascending) noexcept SearchPolicy const policy = SearchPolicy::ascending) noexcept
-> Results { -> Results {
return search_solution_impl<false>(n, candidate_order, nullptr); return search_solution_impl<false>(n, policy, nullptr);
} }
auto search_solution_instrumented(size_t const n, auto search_solution_instrumented(size_t const n,
SearchCounters &counters, SearchCounters &counters,
CandidateOrder const candidate_order = SearchPolicy const policy =
CandidateOrder::ascending) noexcept SearchPolicy::ascending) noexcept
-> Results { -> Results {
counters = {}; counters = {};
return search_solution_impl<true>(n, candidate_order, &counters); return search_solution_impl<true>(n, policy, &counters);
} }
/** Construct an odd-order solution from its even-order predecessor. */ /** Construct an odd-order solution from its even-order predecessor. */
@@ -335,20 +345,25 @@ namespace {
return n >= 9 && n % 2 == 1; return n >= 9 && n % 2 == 1;
} }
auto find_solution(size_t const n) noexcept -> Results { auto find_solution(
size_t const n,
SearchPolicy const policy = SearchPolicy::ascending) noexcept -> Results {
if (uses_odd_construction(n)) { if (uses_odd_construction(n)) {
return construct_odd_solution(n, search_solution(n - 1)); return construct_odd_solution(n, search_solution(n - 1, policy));
} }
return search_solution(n); return search_solution(n, policy);
} }
auto find_solution_instrumented(size_t const n, auto find_solution_instrumented(size_t const n,
SearchCounters &counters) noexcept -> Results { SearchCounters &counters,
SearchPolicy const policy =
SearchPolicy::ascending) noexcept
-> Results {
if (uses_odd_construction(n)) { if (uses_odd_construction(n)) {
return construct_odd_solution( return construct_odd_solution(
n, search_solution_instrumented(n - 1, counters)); n, search_solution_instrumented(n - 1, counters, policy));
} }
return search_solution_instrumented(n, counters); return search_solution_instrumented(n, counters, policy);
} }
} // anon namespace } // anon namespace
+20 -4
View File
@@ -249,8 +249,10 @@ namespace {
auto test_odd_solver_route() -> int { auto test_odd_solver_route() -> int {
SearchCounters even_counters; SearchCounters even_counters;
SearchCounters odd_counters; SearchCounters odd_counters;
auto const even = find_solution_instrumented(8, even_counters); auto const even =
auto const odd = find_solution_instrumented(9, odd_counters); find_solution_instrumented(8, even_counters, SearchPolicy::best_fit);
auto const odd =
find_solution_instrumented(9, odd_counters, SearchPolicy::best_fit);
int failures = 0; int failures = 0;
failures += expect( failures += expect(
@@ -358,7 +360,7 @@ namespace {
SearchCounters descending_counters; SearchCounters descending_counters;
auto const descending = search_solution_instrumented( auto const descending = search_solution_instrumented(
8, descending_counters, CandidateOrder::descending); 8, descending_counters, SearchPolicy::descending);
auto validation = validate(8, descending); auto validation = validate(8, descending);
failures += expect( failures += expect(
validation.valid(), validation.valid(),
@@ -367,7 +369,7 @@ namespace {
SearchCounters direct_nine_counters; SearchCounters direct_nine_counters;
auto const direct_nine = search_solution_instrumented( auto const direct_nine = search_solution_instrumented(
9, direct_nine_counters, CandidateOrder::ascending); 9, direct_nine_counters, SearchPolicy::ascending);
validation = validate(9, direct_nine); validation = validate(9, direct_nine);
failures += expect( failures += expect(
validation.valid(), validation.valid(),
@@ -376,6 +378,20 @@ namespace {
failures += expect( failures += expect(
direct_nine_counters.search_nodes != descending_counters.search_nodes, direct_nine_counters.search_nodes != descending_counters.search_nodes,
"direct order-9 coverage unexpectedly reused predecessor construction"); "direct order-9 coverage unexpectedly reused predecessor construction");
SearchCounters ascending_exhaustive;
SearchCounters descending_exhaustive;
SearchCounters best_fit_exhaustive;
static_cast<void>(search_solution_instrumented(
5, ascending_exhaustive, SearchPolicy::ascending));
static_cast<void>(search_solution_instrumented(
5, descending_exhaustive, SearchPolicy::descending));
static_cast<void>(search_solution_instrumented(
5, best_fit_exhaustive, SearchPolicy::best_fit));
failures += expect(
ascending_exhaustive.search_nodes == descending_exhaustive.search_nodes &&
ascending_exhaustive.search_nodes == best_fit_exhaustive.search_nodes,
"candidate policy changed the exhaustive skyline search space");
return failures; return failures;
} }