On this page

Design

The fast C++ engine changes storage and allocation while preserving the C++ reference's observable order. It was built for search, where one decision applies the rules and evaluates a leaf many times. Integer mechanics could be reorganized; chance order and floating-point expressions stayed fixed.

Implementation

One scan per wave

The reference counts a numbered disc's row and column by walking outward from that disc. The fast engine builds seven row masks, seven column masks and one numbered-cell bitboard in a single pass. A lookup table then answers the run length at every occupied position.

fast-engine.hppTurn the board into occupancy masks
C++lines 121–172
inline const std::array<RunInfo8, 128> kRunLengthTable = [] {
  std::array<RunInfo8, 128> table{};
  for (int mask = 0; mask < 128; ++mask) {
    RunInfo8& info = table[static_cast<std::size_t>(mask)];
    for (int position = 0; position < kBoardSize; ++position) {
      info.length[position] = 0;
    }
    int cursor = 0;
    while (cursor < kBoardSize) {
      if (((mask >> cursor) & 1) == 0) {
        ++cursor;
        continue;
      }
      const int start = cursor;
      while (cursor < kBoardSize && ((mask >> cursor) & 1) != 0) ++cursor;
      for (int position = start; position < cursor; ++position) {
        info.length[position] = static_cast<std::uint8_t>(cursor - start);
      }
    }
  }
  return table;
}();

// One pass over the board produces the seven row occupancy masks, the seven
// column occupancy masks and the bitboard of numbered cells.  The cover
// bitboard is deliberately NOT produced here: better than half of all move
// applications resolve with no wave at all, and those never need it.
inline void scanBoard(const Board& board, BoardScan& scan) {
  unsigned rows[kBoardSize];
  std::uint64_t numbered = 0;
  for (int row = 0; row < kBoardSize; ++row) {
    const std::uint8_t* cells = board.data() + row * kBoardSize;
    unsigned occupied = 0;
    unsigned numbers = 0;
    for (int column = 0; column < kBoardSize; ++column) {
      const unsigned cell = cells[column];
      occupied |= static_cast<unsigned>(cell != 0u) << column;
      numbers |= static_cast<unsigned>(cell - 1u < 7u) << column;
    }
    rows[row] = occupied;
    scan.row_mask[row] = static_cast<std::uint8_t>(occupied);
    numbered |= static_cast<std::uint64_t>(numbers) << (row * kBoardSize);
  }
  for (int column = 0; column < kBoardSize; ++column) {
    unsigned mask = 0;
    for (int row = 0; row < kBoardSize; ++row) {
      mask |= ((rows[row] >> column) & 1u) << row;
    }
    scan.column_mask[column] = static_cast<std::uint8_t>(mask);
  }
  scan.numbered = numbered;
}
The scan records the same board in the shapes needed by row checks, column checks and numbered-disc iteration.

Gravity only where a hole can exist

A wave can leave holes only in columns where a disc popped. Reveals replace gray discs in place, so they do not make a new gap. The cascade records the touched columns as a bit mask and compacts those columns in place.

fast-engine.hppCompact the changed columns in place
C++lines 220–239
// Only a column that lost a disc can have a hole; reveals overwrite a cover in
// place and leave the column contiguous.  Compacting just the affected columns
// is therefore identical to compacting all seven.
inline void applyGravityInPlace(Board& board, unsigned columns) {
  while (columns != 0) {
    const int column = __builtin_ctz(columns);
    columns &= columns - 1;
    int destination = kBoardSize - 1;
    for (int row = kBoardSize - 1; row >= 0; --row) {
      const std::uint8_t cell =
          board[static_cast<std::size_t>(row * kBoardSize + column)];
      if (cell == kEmpty) continue;
      board[static_cast<std::size_t>(destination * kBoardSize + column)] = cell;
      --destination;
    }
    for (int row = destination; row >= 0; --row) {
      board[static_cast<std::size_t>(row * kBoardSize + column)] = kEmpty;
    }
  }
}
Each set bit selects one column, and the bottom-up copy preserves disc order while clearing the space above it.

No wave allocation inside search

Full-game replays need every wave. Search needs only the count and the last chain depth. A templated sink lets the same move loop write either representation, so the hot path avoids constructing a vector it never reads.

fast-engine.hppChoose the wave record at compile time
C++lines 259–296
// ---------------------------------------------------------------------------
// O5.  Wave sinks.  The search discards MoveResult::waves entirely; only
// `empty()` and `back().depth` are consulted inside playMove.  A vector is
// therefore a per-node heap allocation for data nobody reads.  Trajectory
// consumers get the full list through FullWaveSink, which stores it inline.
// ---------------------------------------------------------------------------

struct MinimalWaveSink {
  int count = 0;
  int last_depth = 0;

  void push(const Wave& wave) {
    ++count;
    last_depth = wave.depth;
  }
  bool empty() const { return count == 0; }
  int backDepth() const { return last_depth; }
};

// A move can pop at most 49 discs, and every wave clears at least one disc, so
// a single move produces at most 49 waves before the rise and at most 49 after
// it.  128 is a hard upper bound with margin; overflow throws rather than
// silently truncating.
inline constexpr int kMaximumWavesPerMove = 128;

struct FullWaveSink {
  std::array<Wave, kMaximumWavesPerMove> waves;
  int count = 0;

  void push(const Wave& wave) {
    if (count >= kMaximumWavesPerMove) {
      throw std::runtime_error("fast engine: wave capacity exceeded");
    }
    waves[static_cast<std::size_t>(count++)] = wave;
  }
  bool empty() const { return count == 0; }
  int backDepth() const { return waves[static_cast<std::size_t>(count - 1)].depth; }
};
Search receives a two-field sink, while trajectory tools retain the bounded full record needed for parity.

A packed search key

The search cache stores the board, next disc, moves to the next rise and depth in four machine words. Its open-addressed table and intrusive least-recently-used list keep the reference eviction behavior without allocating strings and list nodes at interior search states.

fast-search.hppPack every cache field into four words
C++lines 49–80
struct PackedKey {
  std::uint64_t words[4] = {0, 0, 0, 0};

  bool operator==(const PackedKey& other) const {
    return words[0] == other.words[0] && words[1] == other.words[1] &&
           words[2] == other.words[2] && words[3] == other.words[3];
  }
};

// Injective on the reachable domain: cells are 0..9 (4 bits), next_disc 1..7,
// moves_remaining 1..5, depth 1..8.  gate-search asserts the domain bounds on
// every key it builds.
inline PackedKey packKey(const State& state, int depth) {
  PackedKey key;
  const std::uint8_t* cells = state.board.data();
  for (int group = 0; group < 3; ++group) {
    std::uint64_t word = 0;
    for (int offset = 0; offset < 16; ++offset) {
      word |= static_cast<std::uint64_t>(cells[group * 16 + offset] & 0x0fu)
              << (4 * offset);
    }
    key.words[group] = word;
  }
  key.words[3] = static_cast<std::uint64_t>(cells[48] & 0x0fu) |
                 (static_cast<std::uint64_t>(state.next_disc) << 8) |
                 (static_cast<std::uint64_t>(
                      static_cast<std::uint32_t>(state.moves_remaining))
                  << 16) |
                 (static_cast<std::uint64_t>(static_cast<std::uint32_t>(depth))
                  << 24);
  return key;
}
Four bits per cell leave room for the scalar search fields while keeping key equality to four integer comparisons.

Uses

This is the main CPU engine for fair-search cohorts under lifetime-objective. It also supports leaf evolution, learned-leaf experiments and the native fair policy in the scripted-round playground. The reference engine remains the semantic anchor; this implementation is the high-throughput execution path.

Verification

On 24 real depth-4, five-strata decisions with three interleaved repeats, the complete engine, cache and leaf path measured 3.08 times the reference on the shared host (finding-13). That is an engineering result. It does not measure policy strength.

Technical recordTrajectory, search and leaf gates behind the speed result
  • The trajectory gate compared 438,020 moves and 548,263 waves across 8,288 games with no mismatch (finding-13).
  • Search parity covered 306 moves across nine depth and strata configurations, with matching actions, work and completed depth (finding-13).
  • The leaf gate compared 225,183 real states by floating-point bit pattern, with no mismatch (finding-13).

Limits

The measurements came from a heavily shared machine, so the retained record treats back-to-back ratios as the useful quantity and leaves absolute timing unpromoted. The implementation has no latent-board mode, and its native builds and gates were validated with clang++ only.

Agent contextEquivalence contract, build entry points and required fast-engine gates

The source contract at the top of fast-engine.hpp permits integer, layout and allocation changes. Do not reorder chance draws or floating-point expressions. The reference order includes row-major poppers, row-major reveals, complete wave order and strict cache eviction behavior.

Build with approaches/lifetime-objective/fast-engine/build.sh. Before using a change, run gate-leaf, gate-search and gate-trajectory from the generated build directory. The retained methodology and measurement details are in finding-13.