Files
partridge-cpp/tests/tests.cc
T
Codex instance f37e08768d solver: break board dihedral symmetry
Constrain the unique unit square to a closed D4 fundamental region once its placement is known. This preserves one representative of every board-orientation orbit without assigning identities to repeated squares.

Keep a symmetry-disabled benchmark path, document the proof and measurements, and cover generic, diagonal, midline, corner, and centre orbits.

Tests: Release, Debug, ASan, and UBSan CTest (11 passed each)

Refs: #3
2026-07-30 18:18:45 +01:00

565 lines
21 KiB
C++

/*
* Copyright 2025, Matthew Gretton-Dann
* SPDX-License-Identifier: Apache-2.0
*/
#define PARTRIDGE_TESTING
#include "../main.cc"
#include <algorithm>
#include <array>
#include <span>
#include <sstream>
#include <string>
namespace {
struct Placement {
std::uint64_t x;
std::uint64_t y;
std::uint64_t side;
auto operator==(Placement const &) const noexcept -> bool = default;
};
struct Validation {
std::vector<std::string> diagnostics;
[[nodiscard]] auto valid() const noexcept -> bool { return diagnostics.empty(); }
[[nodiscard]] auto text() const -> std::string {
std::ostringstream result;
for (auto const &diagnostic: diagnostics) {
result << diagnostic << '\n';
}
return result.str();
}
};
struct IndependentResult {
std::uint64_t width;
std::uint64_t height;
std::vector<Placement> placements;
};
auto describe(std::size_t index, Placement const &placement) -> std::string {
return "placement " + std::to_string(index) + " at (" +
std::to_string(placement.x) + ", " + std::to_string(placement.y) +
") with side " + std::to_string(placement.side);
}
auto validate(std::uint64_t order, std::uint64_t width, std::uint64_t height,
std::vector<Placement> const &placements) -> Validation {
Validation result;
auto const expected_side = order * (order + 1) / 2;
if (width != expected_side || height != expected_side) {
result.diagnostics.emplace_back(
"board dimensions must both equal the triangular number for the order");
}
std::vector<std::uint64_t> multiplicities(order + 1);
std::vector<int> occupied(width * height, -1);
for (std::size_t index = 0; index < placements.size(); ++index) {
auto const &placement = placements[index];
if (placement.side == 0 || placement.side > order) {
result.diagnostics.push_back(describe(index, placement) +
" has an invalid side length");
continue;
}
++multiplicities[placement.side];
if (placement.x >= width || placement.y >= height ||
placement.side > width - placement.x ||
placement.side > height - placement.y) {
result.diagnostics.push_back(describe(index, placement) +
" is outside the board bounds");
continue;
}
int overlapping_placement = -1;
for (auto y = placement.y; y < placement.y + placement.side; ++y) {
for (auto x = placement.x; x < placement.x + placement.side; ++x) {
auto &cell = occupied[x + y * width];
if (cell != -1) {
overlapping_placement = cell;
} else {
cell = static_cast<int>(index);
}
}
}
if (overlapping_placement != -1) {
result.diagnostics.push_back(
describe(index, placement) + " overlaps placement " +
std::to_string(overlapping_placement));
}
}
for (std::uint64_t side = 1; side <= order; ++side) {
if (multiplicities[side] != side) {
result.diagnostics.push_back(
"side " + std::to_string(side) + " has multiplicity " +
std::to_string(multiplicities[side]) + "; expected " +
std::to_string(side));
}
}
if (std::ranges::find(occupied, -1) != occupied.end()) {
result.diagnostics.emplace_back("board is not completely covered");
}
return result;
}
auto to_independent(Results const &result) -> IndependentResult {
IndependentResult converted{result.length(), result.length(), {}};
converted.placements.reserve(result.squares().size());
for (auto const &square: result.squares()) {
converted.placements.push_back({
square.pos() % result.length(),
square.pos() / result.length(),
square.length(),
});
}
return converted;
}
auto validate(std::uint64_t order, Results const &result) -> Validation {
auto const converted = to_independent(result);
return validate(order, converted.width, converted.height,
converted.placements);
}
auto known_order_8() -> std::vector<Placement> {
return {
{0, 0, 8}, {8, 0, 8}, {16, 0, 8}, {24, 0, 8},
{32, 0, 4}, {32, 4, 4}, {0, 8, 8}, {8, 8, 8},
{16, 8, 8}, {24, 8, 6}, {30, 8, 6}, {24, 14, 5},
{29, 14, 7}, {0, 16, 6}, {6, 16, 3}, {9, 16, 8},
{17, 16, 7}, {6, 19, 3}, {24, 19, 5}, {29, 21, 7},
{0, 22, 7}, {7, 22, 2}, {17, 23, 1}, {18, 23, 6},
{7, 24, 6}, {13, 24, 5}, {24, 24, 5}, {29, 28, 3},
{32, 28, 4}, {0, 29, 7}, {13, 29, 7}, {20, 29, 7},
{27, 29, 2}, {7, 30, 6}, {27, 31, 5}, {32, 32, 4},
};
}
auto renderable_result(std::uint64_t side,
std::vector<Placement> const &placements) -> Results {
std::vector<Square> squares;
squares.reserve(placements.size());
for (auto const &placement: placements) {
squares.emplace_back(placement.x + placement.y * side, placement.side);
}
return Results(side, std::move(squares));
}
auto expect(bool condition, std::string const &message) -> int {
if (condition) {
return 0;
}
std::cerr << "FAIL: " << message << '\n';
return 1;
}
auto has(Validation const &validation, std::string_view diagnostic) -> bool {
return std::ranges::any_of(validation.diagnostics,
[diagnostic](std::string const &candidate) {
return candidate.find(diagnostic) != std::string::npos;
});
}
auto d4_orbit(Placement const &placement, std::uint64_t const length)
-> std::array<Placement, 8> {
auto const far_x = length - 1 - placement.x;
auto const far_y = length - 1 - placement.y;
return {{
{placement.x, placement.y, placement.side},
{far_y, placement.x, placement.side},
{far_x, far_y, placement.side},
{placement.y, far_x, placement.side},
{far_x, placement.y, placement.side},
{far_y, far_x, placement.side},
{placement.x, far_y, placement.side},
{placement.y, placement.x, placement.side},
}};
}
auto canonical_members(Placement const &placement, std::uint64_t const length)
-> std::vector<Placement> {
auto orbit = d4_orbit(placement, length);
std::vector<Placement> members;
for (auto const &member: orbit) {
if (canonical_unit_position(member.x, member.y, length) &&
std::ranges::find(members, member) == members.end()) {
members.push_back(member);
}
}
return members;
}
auto test_validator() -> int {
int failures = 0;
auto const valid = known_order_8();
auto validation = validate(8, 36, 36, valid);
failures += expect(validation.valid(),
"known order-8 solution was rejected:\n" + validation.text());
validation = validate(8, 35, 36, valid);
failures += expect(has(validation, "dimensions"),
"invalid board dimensions were not diagnosed");
auto invalid = valid;
invalid.pop_back();
validation = validate(8, 36, 36, invalid);
failures += expect(has(validation, "side 4 has multiplicity 3; expected 4"),
"invalid square multiplicity lacked side and counts");
failures += expect(has(validation, "completely covered"),
"incomplete coverage was not diagnosed");
invalid = valid;
invalid.front().x = 36;
validation = validate(8, 36, 36, invalid);
failures += expect(
has(validation,
"placement 0 at (36, 0) with side 8 is outside the board bounds"),
"out-of-bounds diagnostic lacked placement details");
invalid = valid;
invalid[1].x = invalid[0].x;
invalid[1].y = invalid[0].y;
validation = validate(8, 36, 36, invalid);
failures += expect(
has(validation,
"placement 1 at (0, 0) with side 8 overlaps placement 0"),
"overlap diagnostic lacked placement details");
invalid = valid;
invalid.front().side = 9;
validation = validate(8, 36, 36, invalid);
failures += expect(
has(validation,
"placement 0 at (0, 0) with side 9 has an invalid side length"),
"invalid-side diagnostic lacked placement details");
return failures;
}
auto test_construction() -> int {
auto predecessor = renderable_result(36, known_order_8());
auto const predecessor_count = predecessor.squares().size();
auto const constructed = construct_odd_solution(9, std::move(predecessor));
auto const converted = to_independent(constructed);
auto const validation = validate(9, constructed);
int failures = 0;
failures += expect(
validation.valid(),
"even-to-odd construction was rejected:\n" + validation.text());
failures += expect(constructed.length() == 45,
"constructed board has the wrong side length");
failures += expect(converted.placements.size() == predecessor_count + 9,
"construction did not add exactly nine squares");
auto const expected_border = std::array<Placement, 9>{{
{36, 0, 9}, {36, 9, 9}, {36, 18, 9}, {36, 27, 9},
{0, 36, 9}, {9, 36, 9}, {18, 36, 9}, {27, 36, 9},
{36, 36, 9},
}};
failures += expect(
std::ranges::equal(
std::span(converted.placements).subspan(predecessor_count),
expected_border),
"constructed border coordinates are incorrect");
failures += expect(
std::ranges::equal(
std::span(converted.placements).first(predecessor_count),
known_order_8()),
"construction translated predecessor coordinates unexpectedly");
return failures;
}
auto test_odd_solver_route() -> int {
SearchCounters even_counters;
SearchCounters odd_counters;
auto const even =
find_solution_instrumented(8, even_counters, SearchPolicy::best_fit);
auto const odd =
find_solution_instrumented(9, odd_counters, SearchPolicy::best_fit);
int failures = 0;
failures += expect(
!uses_odd_construction(1) && !uses_odd_construction(7) &&
!uses_odd_construction(8) && uses_odd_construction(9) &&
!uses_odd_construction(10) && uses_odd_construction(11),
"odd construction route does not preserve direct handling boundaries");
auto const validation = validate(9, odd);
failures += expect(validation.valid(),
"order-9 routed result is invalid:\n" + validation.text());
failures += expect(odd.squares().size() == even.squares().size() + 9,
"order-9 route did not construct from order 8");
failures += expect(
odd_counters.search_nodes == even_counters.search_nodes &&
odd_counters.loop_iterations == even_counters.loop_iterations &&
odd_counters.attempted_placements ==
even_counters.attempted_placements &&
odd_counters.backtracks == even_counters.backtracks,
"order-9 route did not perform exactly the order-8 search");
return failures;
}
auto test_rendering() -> int {
auto const result = renderable_result(36, known_order_8());
std::ostringstream rendered;
auto *const original_buffer = std::cout.rdbuf(rendered.rdbuf());
result.output();
std::cout.rdbuf(original_buffer);
int failures = 0;
std::istringstream lines(rendered.str());
std::string line;
std::uint64_t line_count = 0;
while (std::getline(lines, line)) {
++line_count;
failures += expect(line.size() == result.length(),
"rendered row has incorrect width");
failures += expect(line.find('.') == std::string::npos,
"valid solution left an unrendered cell");
}
failures += expect(line_count == result.length(),
"rendered output has incorrect height");
return failures;
}
auto test_small_solver() -> int {
int failures = 0;
for (auto const order: std::array<std::uint64_t, 2>{2, 3}) {
auto const solution = find_solution(order);
auto const converted = to_independent(solution);
auto const expected_side = order * (order + 1) / 2;
failures += expect(converted.width == expected_side &&
converted.height == expected_side,
"result adapter returned incorrect board dimensions");
failures += expect(!has(validate(order, solution), "dimensions"),
"result adapter supplied invalid validator dimensions");
failures += expect(converted.placements.empty(),
"solver reported a solution for an unsatisfiable order");
}
Results const encoded(10, {Square(23, 2)});
auto const converted = to_independent(encoded);
failures += expect(
converted.width == 10 && converted.height == 10 &&
converted.placements.size() == 1 &&
converted.placements.front().x == 3 &&
converted.placements.front().y == 2 &&
converted.placements.front().side == 2,
"result adapter did not convert encoded placement coordinates");
return failures;
}
auto test_search_counters() -> int {
SearchCounters counters;
auto const solution = find_solution_instrumented(2, counters);
int failures = 0;
failures += expect(solution.squares().empty(),
"instrumented solver changed an infeasible result");
failures += expect(counters.search_nodes > 0 &&
counters.loop_iterations >= counters.search_nodes,
"instrumented solver did not count search work");
failures += expect(counters.attempted_placements > 0 &&
counters.backtracks > 0,
"instrumented solver did not count placements/backtracks");
failures += expect(counters.prune_checks >= counters.prune_hits &&
counters.generated_tasks == 0 &&
counters.completed_tasks == 0,
"search counters are inconsistent");
return failures;
}
auto test_d4_symmetry() -> int {
int failures = 0;
// A generic cell has eight distinct images: identity, three rotations,
// and four reflected rotations. Exactly one must survive.
auto const generic = d4_orbit({5, 9, 1}, 36);
auto distinct_generic = std::vector<Placement>(generic.begin(),
generic.end());
std::ranges::sort(
distinct_generic, {}, [](Placement const &placement) {
return std::array{placement.x, placement.y};
});
distinct_generic.erase(
std::ranges::unique(distinct_generic).begin(),
distinct_generic.end());
failures += expect(distinct_generic.size() == generic.size(),
"generic D4 test point does not have eight images");
for (std::size_t index = 0; index < generic.size(); ++index) {
failures += expect(
canonical_members(generic[index], 36) ==
std::vector<Placement>{{5, 9, 1}},
"D4 transform " + std::to_string(index) +
" did not select the same canonical representative");
}
// Closed boundaries are important because diagonal, midline, corner, and
// centre cells have non-trivial stabilizers and therefore smaller orbits.
for (auto const test_case:
std::array<std::pair<std::uint64_t, Placement>, 8>{{
{36, {0, 0, 1}},
{36, {0, 17, 1}},
{36, {7, 7, 1}},
{36, {7, 17, 1}},
{45, {0, 22, 1}},
{45, {11, 11, 1}},
{45, {11, 22, 1}},
{45, {22, 22, 1}},
}}) {
failures += expect(
canonical_members(test_case.second, test_case.first).size() == 1,
"boundary orbit did not retain exactly one distinct representative");
}
failures += expect(
canonical_unit_position(7, 17, 36) &&
!canonical_unit_position(8, 7, 36) &&
!canonical_unit_position(7, 18, 36) &&
canonical_unit_position(22, 22, 45) &&
!canonical_unit_position(22, 23, 45),
"canonical triangle mishandled a diagonal or midline boundary");
SearchCounters enabled_counters;
SearchCounters disabled_counters;
auto const enabled = search_solution_instrumented(
8, enabled_counters, SearchPolicy::ascending,
SymmetryBreaking::d4_unit_square);
auto const disabled = search_solution_instrumented(
8, disabled_counters, SearchPolicy::ascending,
SymmetryBreaking::disabled);
auto enabled_validation = validate(8, enabled);
auto disabled_validation = validate(8, disabled);
failures += expect(
enabled_validation.valid(),
"symmetry-enabled solution failed independent validation:\n" +
enabled_validation.text());
failures += expect(
disabled_validation.valid(),
"symmetry-disabled solution failed independent validation:\n" +
disabled_validation.text());
auto const unit = std::ranges::find_if(
enabled.squares(),
[](Square const &square) { return square.length() == 1; });
failures += expect(
unit != enabled.squares().end() &&
canonical_unit_position(unit->pos() % enabled.length(),
unit->pos() / enabled.length(),
enabled.length()),
"symmetry-enabled search placed the unique unit square outside the "
"canonical region");
failures += expect(
enabled_counters.prune_checks > 0 && enabled_counters.prune_hits > 0 &&
disabled_counters.prune_checks == 0 &&
disabled_counters.prune_hits == 0,
"symmetry instrumentation did not distinguish enabled and disabled "
"search");
return failures;
}
auto test_skyline_search() -> int {
int failures = 0;
auto const narrowest =
smallest_valley(std::vector<std::uint64_t>{4, 2, 2, 4, 0, 0, 0, 4});
failures += expect(
narrowest.x == 1 && narrowest.height == 2 && narrowest.width == 2,
"skyline did not select the smallest-width valley");
auto const tie =
smallest_valley(std::vector<std::uint64_t>{4, 1, 4, 4, 2, 4});
failures += expect(tie.x == 1 && tie.height == 1 && tie.width == 1,
"skyline valley tie-break is not deterministic");
SearchCounters descending_counters;
auto const descending = search_solution_instrumented(
8, descending_counters, SearchPolicy::descending);
auto validation = validate(8, descending);
failures += expect(
validation.valid(),
"descending skyline search returned an invalid order-8 solution:\n" +
validation.text());
SearchCounters direct_nine_counters;
auto const direct_nine = search_solution_instrumented(
9, direct_nine_counters, SearchPolicy::ascending);
validation = validate(9, direct_nine);
failures += expect(
validation.valid(),
"direct skyline search returned an invalid order-9 solution:\n" +
validation.text());
failures += expect(
direct_nine_counters.search_nodes != descending_counters.search_nodes,
"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;
}
auto test_solver_completion() -> int {
int failures = 0;
for (auto const order: std::array<std::uint64_t, 2>{1, 8}) {
auto const solution = find_solution(order);
auto const validation = validate(order, solution);
failures += expect(
validation.valid(),
"solver returned an invalid order-" + std::to_string(order) +
" solution:\n" + validation.text());
}
return failures;
}
}
int main(int argc, char **argv) {
if (argc != 2) {
std::cerr << "usage: partridge_tests TEST-NAME\n";
return 2;
}
auto const test = std::string_view(argv[1]);
if (test == "validator") {
return test_validator();
}
if (test == "construction") {
return test_construction();
}
if (test == "solver-odd-route") {
return test_odd_solver_route();
}
if (test == "rendering") {
return test_rendering();
}
if (test == "solver-small") {
return test_small_solver();
}
if (test == "solver-completion") {
return test_solver_completion();
}
if (test == "search-counters") {
return test_search_counters();
}
if (test == "skyline-search") {
return test_skyline_search();
}
if (test == "d4-symmetry") {
return test_d4_symmetry();
}
std::cerr << "unknown test: " << test << '\n';
return 2;
}