Skip to content

example.sudoku: assert in examine_potentials aborts the example in non-NDEBUG builds #2105

Description

@yunyi1201

The example.sudoku example aborts on its very first solve in any build that does not define NDEBUG (e.g. -DCMAKE_BUILD_TYPE=Debug, sanitizer builds, distro packages built without -DNDEBUG):

$ cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DSTDEXEC_BUILD_EXAMPLES=ON
$ cmake --build build --target example.sudoku
$ ./build/examples/example.sudoku
Assertion failed: (!"potential set is not a power of 2"),
function examine_potentials, file sudoku.cpp, line 282.
Abort trap: 6     # exit 134

Reproduces on current main (fee4d651), macOS arm64, Apple Clang, -std=gnu++20. The flags emitted by CMake for the Debug configuration are simply -g -std=gnu++20 ..., with no -DNDEBUG, so assert is active.

Diagnosis

Adding a print before the failing assert shows the very first call into examine_potentials already hits the default branch:

DBG: cell i=0 solved_element=1 potential_set=0x0

i.e. cell 0 of init_values0 (which starts as a fixed 1) reaches examine_potentials with potential_set == 0, falls through the early "empty set" check, and lands in the switch's default.

Root cause

calculate_potentials unconditionally clears potential_set for every cell and only fills in candidate bits for unsolved cells:

for (unsigned i = 0; i < BOARD_SIZE; ++i) {
  b[i].potential_set = 0;
  if (!b[i].solved_element) {
    // ... compute candidate bitmask ...
    b[i].potential_set |= 1 << (potential - 1);
  }
}

Every already-solved cell therefore enters examine_potentials with potential_set == 0. The early-out at the top of examine_potentials only treats potential_set == 0 as "empty set / dead branch" when the cell is also unsolved:

if (b[i].solved_element == 0 && b[i].potential_set == 0)  // empty set
  return false;
switch (b[i].potential_set) {
  case 1:  /* singleton -> promote */ break;
  ...
  case 256: ...
  default:
    assert(!"potential set is not a power of 2");   // sudoku.cpp:282
}

For an already-solved cell (solved_element != 0, potential_set == 0) control falls through into switch(0) -> default -> assert. There is also a second legal state the switch does not cover: an unsolved cell with multiple candidates, where potential_set is e.g. 3 = 1|2 or 7 = 1|2|4 and is not a power of two.

In other words, the assertion encodes an invariant ("potential_set is always a power of two here") that the function itself never establishes. Its actual intent is to promote singleton candidate sets into solved_element and leave every other cell untouched.

Suggested fix

Make the singleton filter explicit, instead of relying on the default assert being compiled out:

bool examine_potentials(board_element *b, bool *progress)
{
  bool singletons = false;
  for (unsigned i = 0; i < BOARD_SIZE; ++i)
  {
    if (b[i].solved_element != 0)
      continue;                      // already fixed; potential_set is 0 by construction
    if (b[i].potential_set == 0)     // empty set -> dead branch
      return false;
    // multiple candidates (not a power of 2): leave for partial_solve to branch on
    if ((b[i].potential_set & (b[i].potential_set - 1)) != 0)
      continue;
    switch (b[i].potential_set) {
      /* unchanged singleton -> solved_element mapping */
    }
  }
  *progress = singletons;
  return valid_board(b);
}

x & (x - 1) == 0 is the standard power-of-two test (x == 0 already excluded above). With the filter in place the default branch becomes genuinely unreachable; I'd remove it, but happy to keep it as a defensive assert if maintainers prefer.

After this change, example.sudoku runs to completion in both Debug and Release on all four built-in boards, in both find-one and full-enumeration modes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions