Drop7 Research
approaches/heuristic-search/rollout/rollout.cppC++1,177 lines · 43.4 KB
#include "../../../src/core/native/engine.hpp"

#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <limits>
#include <numeric>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/resource.h>
#include <tuple>
#include <utility>
#include <vector>

namespace {

using Clock = std::chrono::steady_clock;
using drop7::Board;
using drop7::MoveResult;
using drop7::State;

constexpr std::uint32_t kTrainingSeedStart = 0x3d70'0000u;
constexpr std::uint32_t kProbeSeedStart = 0x4d70'0000u;
constexpr std::uint32_t kPlannerDomain = 0x524f'4c4cu;  // "ROLL"
constexpr std::uint32_t kOneStepDomain = 0x4f4e'4553u;  // "ONES"
constexpr int kMaximumHorizon = 100;
constexpr int kMaximumScenarios = 64;
constexpr int kMaximumContinuationSamples = 7;

enum class RiskMode { Mean, LowerHalf, Cvar25, Blend };

struct PlannerOptions {
  int horizon = 25;
  int scenarios = 7;
  int continuation_samples = 1;
  RiskMode risk = RiskMode::Blend;
  double leaf_scale = 1.0;
  double death_penalty = 250'000.0;
  double remaining_death_penalty = 15'000.0;
};

struct RunOptions {
  int games = 4;
  int max_moves = 1000;
  std::uint32_t seed_start = kTrainingSeedStart;
  std::string range = "train";
  PlannerOptions planner;
};

struct PlannerStats {
  std::uint64_t simulated_moves = 0;
  std::uint64_t root_scenarios = 0;
  std::uint64_t one_step_moves = 0;
};

struct CandidateEvaluation {
  int column = -1;
  double utility = -std::numeric_limits<double>::infinity();
  double mean = -std::numeric_limits<double>::infinity();
  double lower_half = -std::numeric_limits<double>::infinity();
  double cvar25 = -std::numeric_limits<double>::infinity();
  std::vector<double> returns;
};

struct Decision {
  int column = -1;
  std::array<CandidateEvaluation, drop7::kBoardSize> candidates{};
  int candidate_count = 0;
};

struct GameResult {
  std::uint32_t seed = 0;
  std::int64_t score = 0;
  int moves = 0;
  int level = 1;
  bool censored = false;
  std::uint64_t simulated_moves = 0;
};

struct LineAnalysis {
  std::array<int, drop7::kCellCount> horizontal_lengths{};
  std::array<int, drop7::kCellCount> horizontal_starts{};
  std::array<int, drop7::kCellCount> horizontal_ends{};
  std::array<int, drop7::kCellCount> vertical_lengths{};
  std::array<int, drop7::kCellCount> vertical_starts{};
  std::array<int, drop7::kCellCount> vertical_ends{};
};

struct DiscAnalysis {
  bool present = false;
  int value = 0;
  int row = 0;
  int column = 0;
  int horizontal_length = 0;
  int vertical_length = 0;
  double horizontal_addition = 0;
  double vertical_addition = 0;
  double addition = 0;
  double horizontal_release = 0;
  double vertical_release = 0;
  double release = 0;
};

struct PhaseFeatures {
  int open_columns = 0;
  double height_load = 0;
  int solid_cells = 0;
  int cracked_cells = 0;
  int numbered_cells = 0;
  int high_low_numbers = 0;
  double direct_potential = 0;
  double latent_chain_potential = 0;
  double cracked_exposure = 0;
  double solid_exposure = 0;
  double adjacent_ones = 0;
  double triple_twos = 0;
  double dead_low_numbers = 0;
  double projected_occupancy_debt = 0;
  double residual_cover_debt = 0;
  double cover_altitude_debt = 0;
  double imminent_cover_altitude_debt = 0;
  double peak_height_risk = 0;
  double low_cap_load = 0;
  double adjacent_low_cap_load = 0;
  double quiet_build_options = 0;
  double quiet_direct_gain = 0;
  double trigger_readiness = 0;
  double rise_trigger_readiness = 0;
};

std::string valueAfter(int argc, char** argv, std::string_view flag,
                       std::string fallback = {}) {
  for (int index = 1; index + 1 < argc; ++index) {
    if (argv[index] == flag) return argv[index + 1];
  }
  return fallback;
}

bool hasFlag(int argc, char** argv, std::string_view flag) {
  for (int index = 1; index < argc; ++index) {
    if (argv[index] == flag) return true;
  }
  return false;
}

int parsePositive(const std::string& value, std::string_view name) {
  if (value.empty()) throw std::invalid_argument(std::string(name) + " is required");
  std::size_t consumed = 0;
  const long long parsed = std::stoll(value, &consumed, 10);
  if (consumed != value.size() || parsed < 1 ||
      parsed > std::numeric_limits<int>::max()) {
    throw std::invalid_argument(std::string(name) + " must be positive");
  }
  return static_cast<int>(parsed);
}

double parsePositiveDouble(const std::string& value, std::string_view name) {
  if (value.empty()) throw std::invalid_argument(std::string(name) + " is required");
  std::size_t consumed = 0;
  const double parsed = std::stod(value, &consumed);
  if (consumed != value.size() || !std::isfinite(parsed) || parsed <= 0) {
    throw std::invalid_argument(std::string(name) + " must be positive");
  }
  return parsed;
}

RiskMode parseRisk(const std::string& value) {
  if (value == "mean") return RiskMode::Mean;
  if (value == "lower") return RiskMode::LowerHalf;
  if (value == "cvar25") return RiskMode::Cvar25;
  if (value == "blend") return RiskMode::Blend;
  throw std::invalid_argument("--risk must be mean, lower, cvar25, or blend");
}

std::string_view riskName(RiskMode risk) {
  switch (risk) {
    case RiskMode::Mean:
      return "mean";
    case RiskMode::LowerHalf:
      return "lower";
    case RiskMode::Cvar25:
      return "cvar25";
    case RiskMode::Blend:
      return "blend";
  }
  return "unknown";
}

Board mirrorBoard(const Board& board) {
  Board mirrored{};
  for (int row = 0; row < drop7::kBoardSize; ++row) {
    for (int column = 0; column < drop7::kBoardSize; ++column) {
      mirrored[drop7::indexOf(row, drop7::kBoardSize - 1 - column)] =
          board[drop7::indexOf(row, column)];
    }
  }
  return mirrored;
}

bool boardLess(const Board& first, const Board& second) {
  return std::lexicographical_compare(first.begin(), first.end(),
                                      second.begin(), second.end());
}

State canonicalState(const State& state, bool& was_mirrored) {
  const Board reflected = mirrorBoard(state.board);
  was_mirrored = boardLess(reflected, state.board);
  if (!was_mirrored) return state;
  State canonical = state;
  canonical.board = reflected;
  return canonical;
}

std::uint32_t observableHash(const State& canonical) {
  // Deliberately excludes score, level, and moves_played. They are observable,
  // but do not change future mechanics. Excluding them gives a stronger
  // seed-blind test: equal public decision states share exactly one chance set.
  std::uint32_t hash = 0x811c'9dc5u;
  for (std::uint8_t cell : canonical.board) {
    hash ^= static_cast<std::uint32_t>(cell + 1u);
    hash *= 0x0100'0193u;
  }
  hash ^= static_cast<std::uint32_t>(canonical.next_disc) * 0x9e37'79b9u;
  hash ^= static_cast<std::uint32_t>(canonical.moves_remaining) * 0x85eb'ca6bu;
  return drop7::mix32(hash ^ kPlannerDomain);
}

std::uint32_t stratifiedSeed(std::uint32_t base, int stratum) {
  // Rejection-select a deterministic Mulberry stream whose first seven-way
  // draw is in the requested stratum. Across each block of seven scenarios,
  // the first chance variate therefore covers all seven disc buckets exactly.
  const std::uint8_t target = static_cast<std::uint8_t>(stratum % 7 + 1);
  for (std::uint32_t attempt = 0; attempt < 128; ++attempt) {
    const std::uint32_t seed = drop7::mix32(
        base + attempt * 0x9e37'79b9u + 0x6d2b'79f5u);
    drop7::Mulberry32 random(seed);
    if (random.nextDisc() == target) return seed;
  }
  throw std::runtime_error("failed to construct stratified chance stream");
}

std::uint32_t rolloutSeed(std::uint32_t root_hash, int scenario, int step) {
  const int batch = scenario / 7;
  const int rotation = static_cast<int>((root_hash >> 28) % 7u);
  const int stratum = (scenario + step + rotation) % 7;
  const std::uint32_t base = drop7::mix32(
      root_hash ^ kPlannerDomain ^
      (static_cast<std::uint32_t>(batch + 1) * 0x27d4'eb2du) ^
      (static_cast<std::uint32_t>(step + 1) * 0x1656'67b1u));
  return stratifiedSeed(base, stratum);
}

std::uint32_t oneStepSeed(const State& state, int sample) {
  bool ignored = false;
  const State canonical = canonicalState(state, ignored);
  const std::uint32_t hash = observableHash(canonical);
  const int rotation = static_cast<int>((hash >> 24) % 7u);
  const std::uint32_t base = drop7::mix32(
      hash ^ kOneStepDomain ^
      (static_cast<std::uint32_t>(sample / 7 + 1) * 0x94d0'49bbu));
  return stratifiedSeed(base, (sample + rotation) % 7);
}

double readiness(int cost) {
  return cost >= 1 ? std::ldexp(1.0, 1 - cost) : 0.0;
}

double unionReadiness(double first, double second) {
  return 1.0 - (1.0 - first) * (1.0 - second);
}

std::array<int, drop7::kBoardSize> columnHeights(const Board& board) {
  std::array<int, drop7::kBoardSize> heights{};
  for (int column = 0; column < drop7::kBoardSize; ++column) {
    for (int row = 0; row < drop7::kBoardSize; ++row) {
      if (board[drop7::indexOf(row, column)] != drop7::kEmpty) {
        ++heights[column];
      }
    }
  }
  return heights;
}

LineAnalysis analyzeLines(const Board& board) {
  LineAnalysis lines;
  lines.horizontal_starts.fill(-1);
  lines.horizontal_ends.fill(-1);
  lines.vertical_starts.fill(-1);
  lines.vertical_ends.fill(-1);
  for (int row = 0; row < drop7::kBoardSize; ++row) {
    int cursor = 0;
    while (cursor < drop7::kBoardSize) {
      if (board[drop7::indexOf(row, cursor)] == drop7::kEmpty) {
        ++cursor;
        continue;
      }
      const int start = cursor;
      while (cursor < drop7::kBoardSize &&
             board[drop7::indexOf(row, cursor)] != drop7::kEmpty) {
        ++cursor;
      }
      const int end = cursor - 1;
      const int length = end - start + 1;
      for (int column = start; column <= end; ++column) {
        const int index = drop7::indexOf(row, column);
        lines.horizontal_lengths[index] = length;
        lines.horizontal_starts[index] = start;
        lines.horizontal_ends[index] = end;
      }
    }
  }
  for (int column = 0; column < drop7::kBoardSize; ++column) {
    int cursor = 0;
    while (cursor < drop7::kBoardSize) {
      if (board[drop7::indexOf(cursor, column)] == drop7::kEmpty) {
        ++cursor;
        continue;
      }
      const int start = cursor;
      while (cursor < drop7::kBoardSize &&
             board[drop7::indexOf(cursor, column)] != drop7::kEmpty) {
        ++cursor;
      }
      const int end = cursor - 1;
      const int length = end - start + 1;
      for (int row = start; row <= end; ++row) {
        const int index = drop7::indexOf(row, column);
        lines.vertical_lengths[index] = length;
        lines.vertical_starts[index] = start;
        lines.vertical_ends[index] = end;
      }
    }
  }
  return lines;
}

double minimumHorizontalAdditionCost(
    int row, int value, int segment_start, int segment_end,
    int segment_length,
    const std::array<int, drop7::kBoardSize>& heights) {
  if (segment_start < 0 || value <= segment_length) return -1.0;
  const int elevation = drop7::kBoardSize - row;
  int best = std::numeric_limits<int>::max();
  for (int start = 0; start + value <= drop7::kBoardSize; ++start) {
    const int end = start + value - 1;
    if (start > segment_start || end < segment_end) continue;
    if (start > 0 && heights[start - 1] >= elevation) continue;
    if (end + 1 < drop7::kBoardSize && heights[end + 1] >= elevation) continue;
    int cost = 0;
    for (int column = start; column <= end; ++column) {
      cost += std::max(0, elevation - heights[column]);
    }
    if (cost > 0) best = std::min(best, cost);
  }
  return best == std::numeric_limits<int>::max() ? -1.0
                                                  : static_cast<double>(best);
}

double releaseReadiness(int excess, std::vector<double> support) {
  if (excess <= 0 || static_cast<int>(support.size()) < excess) return 0;
  std::sort(support.begin(), support.end(), std::greater<double>());
  return support[excess - 1] * readiness(excess);
}

void placementInventory(const State& state,
                        const std::array<int, drop7::kBoardSize>& heights,
                        PhaseFeatures& features) {
  for (int column = 0; column < drop7::kBoardSize; ++column) {
    const int old_vertical = heights[column];
    if (old_vertical >= drop7::kBoardSize) continue;
    const int new_vertical = old_vertical + 1;
    const int landing_row = drop7::kBoardSize - new_vertical;
    int left = 0;
    for (int target = column - 1; target >= 0; --target) {
      if (state.board[drop7::indexOf(landing_row, target)] == drop7::kEmpty) break;
      ++left;
    }
    int right = 0;
    for (int target = column + 1; target < drop7::kBoardSize; ++target) {
      if (state.board[drop7::indexOf(landing_row, target)] == drop7::kEmpty) break;
      ++right;
    }
    const int new_horizontal = left + 1 + right;
    int triggers = (state.next_disc == new_horizontal ||
                    state.next_disc == new_vertical)
                       ? 1
                       : 0;
    double direct_gain = unionReadiness(
        readiness(static_cast<int>(state.next_disc) - new_horizontal),
        readiness(static_cast<int>(state.next_disc) - new_vertical));

    for (int target = column - left; target <= column + right; ++target) {
      if (target == column) continue;
      const std::uint8_t cell =
          state.board[drop7::indexOf(landing_row, target)];
      if (!drop7::isNumbered(cell)) continue;
      const int old_horizontal = target < column ? left : right;
      const double old_ready = unionReadiness(
          readiness(static_cast<int>(cell) - old_horizontal),
          readiness(static_cast<int>(cell) - heights[target]));
      const double new_ready = unionReadiness(
          readiness(static_cast<int>(cell) - new_horizontal),
          readiness(static_cast<int>(cell) - heights[target]));
      direct_gain += std::max(0.0, new_ready - old_ready);
      if (cell == new_horizontal) ++triggers;
    }
    for (int row = drop7::kBoardSize - old_vertical;
         row < drop7::kBoardSize; ++row) {
      const std::uint8_t cell = state.board[drop7::indexOf(row, column)];
      if (!drop7::isNumbered(cell)) continue;
      direct_gain += std::max(
          0.0, readiness(static_cast<int>(cell) - new_vertical) -
                   readiness(static_cast<int>(cell) - old_vertical));
      if (cell == new_vertical) ++triggers;
    }
    if (triggers > 0) {
      features.trigger_readiness += 0.5 + triggers;
    } else {
      features.quiet_build_options += 1;
      features.quiet_direct_gain =
          std::max(features.quiet_direct_gain, direct_gain);
    }
  }
}

PhaseFeatures extractPhaseFeatures(const State& state) {
  PhaseFeatures features;
  const Board& board = state.board;
  const auto heights = columnHeights(board);
  const LineAnalysis lines = analyzeLines(board);
  std::array<DiscAnalysis, drop7::kCellCount> discs{};
  int occupied = 0;
  int covers = 0;
  int maximum_height = 0;

  for (int column = 0; column < drop7::kBoardSize; ++column) {
    if (board[column] == drop7::kEmpty) ++features.open_columns;
    maximum_height = std::max(maximum_height, heights[column]);
  }

  for (int row = 0; row < drop7::kBoardSize; ++row) {
    const int elevation = drop7::kBoardSize - row;
    for (int column = 0; column < drop7::kBoardSize; ++column) {
      const int index = drop7::indexOf(row, column);
      const std::uint8_t cell = board[index];
      if (cell == drop7::kEmpty) continue;
      ++occupied;
      features.height_load += elevation * elevation;
      if (cell == drop7::kSolid || cell == drop7::kCracked) {
        ++covers;
        if (cell == drop7::kSolid) ++features.solid_cells;
        else ++features.cracked_cells;
        const double cover_factor = cell == drop7::kSolid ? 1.0 : 0.65;
        const double edge_factor =
            column == 0 || column == drop7::kBoardSize - 1 ? 1.3 : 1.0;
        features.cover_altitude_debt +=
            elevation * elevation * cover_factor * edge_factor;
        continue;
      }
      if (!drop7::isNumbered(cell)) continue;
      ++features.numbered_cells;
      if (cell <= 2 && elevation >= 5) ++features.high_low_numbers;
      DiscAnalysis& disc = discs[index];
      disc.present = true;
      disc.value = cell;
      disc.row = row;
      disc.column = column;
      disc.horizontal_length = lines.horizontal_lengths[index];
      disc.vertical_length = lines.vertical_lengths[index];
      disc.vertical_addition =
          cell > heights[column]
              ? readiness(static_cast<int>(cell) - heights[column])
              : 0.0;
      const double horizontal_cost = minimumHorizontalAdditionCost(
          row, cell, lines.horizontal_starts[index],
          lines.horizontal_ends[index], lines.horizontal_lengths[index],
          heights);
      disc.horizontal_addition =
          horizontal_cost < 0 ? 0.0 : readiness(static_cast<int>(horizontal_cost));
      disc.addition = unionReadiness(disc.horizontal_addition,
                                     disc.vertical_addition);
      features.direct_potential += disc.addition;
    }
  }

  for (int index = 0; index < drop7::kCellCount; ++index) {
    DiscAnalysis& disc = discs[index];
    if (!disc.present) continue;
    std::vector<double> horizontal_support;
    std::vector<double> vertical_support;
    for (int column = lines.horizontal_starts[index];
         column <= lines.horizontal_ends[index]; ++column) {
      const int supporter = drop7::indexOf(disc.row, column);
      if (supporter != index && discs[supporter].present) {
        horizontal_support.push_back(discs[supporter].addition);
      }
    }
    for (int row = lines.vertical_starts[index];
         row <= lines.vertical_ends[index]; ++row) {
      const int supporter = drop7::indexOf(row, disc.column);
      if (supporter != index && discs[supporter].present) {
        vertical_support.push_back(discs[supporter].addition);
      }
    }
    disc.horizontal_release = releaseReadiness(
        disc.horizontal_length - disc.value, std::move(horizontal_support));
    disc.vertical_release = releaseReadiness(
        disc.vertical_length - disc.value, std::move(vertical_support));
    disc.release = unionReadiness(disc.horizontal_release,
                                  disc.vertical_release);
    features.latent_chain_potential += disc.release;
    if (disc.value <= 2 && disc.horizontal_length > disc.value &&
        disc.vertical_length > disc.value) {
      features.dead_low_numbers +=
          1.0 - unionReadiness(disc.addition, disc.release);
    }
  }

  for (int row = 0; row < drop7::kBoardSize; ++row) {
    for (int column = 0; column < drop7::kBoardSize; ++column) {
      const int index = drop7::indexOf(row, column);
      if (board[index] == 1) {
        if (column + 1 < drop7::kBoardSize && board[index + 1] == 1) {
          const double escape = std::max(
              unionReadiness(discs[index].vertical_addition,
                             discs[index].vertical_release),
              unionReadiness(discs[index + 1].vertical_addition,
                             discs[index + 1].vertical_release));
          features.adjacent_ones += 1.0 - escape;
        }
        if (row + 1 < drop7::kBoardSize &&
            board[index + drop7::kBoardSize] == 1) {
          const double escape = std::max(
              unionReadiness(discs[index].horizontal_addition,
                             discs[index].horizontal_release),
              unionReadiness(discs[index + drop7::kBoardSize].horizontal_addition,
                             discs[index + drop7::kBoardSize].horizontal_release));
          features.adjacent_ones += 1.0 - escape;
        }
      }
    }
  }

  for (int row = 0; row < drop7::kBoardSize; ++row) {
    int cursor = 0;
    while (cursor < drop7::kBoardSize) {
      if (board[drop7::indexOf(row, cursor)] != 2) {
        ++cursor;
        continue;
      }
      const int start = cursor;
      while (cursor < drop7::kBoardSize &&
             board[drop7::indexOf(row, cursor)] == 2) ++cursor;
      const int excess = cursor - start - 2;
      if (excess > 0) {
        double escape = 0;
        for (int column = start; column < cursor; ++column) {
          const DiscAnalysis& disc = discs[drop7::indexOf(row, column)];
          escape = std::max(escape, unionReadiness(
              disc.vertical_addition, disc.vertical_release));
        }
        features.triple_twos += excess * excess * (1.0 - escape);
      }
    }
  }
  for (int column = 0; column < drop7::kBoardSize; ++column) {
    int cursor = 0;
    while (cursor < drop7::kBoardSize) {
      if (board[drop7::indexOf(cursor, column)] != 2) {
        ++cursor;
        continue;
      }
      const int start = cursor;
      while (cursor < drop7::kBoardSize &&
             board[drop7::indexOf(cursor, column)] == 2) ++cursor;
      const int excess = cursor - start - 2;
      if (excess > 0) {
        double escape = 0;
        for (int row = start; row < cursor; ++row) {
          const DiscAnalysis& disc = discs[drop7::indexOf(row, column)];
          escape = std::max(escape, unionReadiness(
              disc.horizontal_addition, disc.horizontal_release));
        }
        features.triple_twos += excess * excess * (1.0 - escape);
      }
    }
  }

  constexpr std::array<std::array<int, 2>, 4> directions{{
      {{-1, 0}}, {{1, 0}}, {{0, -1}}, {{0, 1}},
  }};
  for (int row = 0; row < drop7::kBoardSize; ++row) {
    for (int column = 0; column < drop7::kBoardSize; ++column) {
      const int index = drop7::indexOf(row, column);
      const std::uint8_t cell = board[index];
      if (cell != drop7::kSolid && cell != drop7::kCracked) continue;
      std::array<double, 4> support{};
      int count = 0;
      for (const auto& direction : directions) {
        const int next_row = row + direction[0];
        const int next_column = column + direction[1];
        if (!drop7::inside(next_row, next_column)) continue;
        const DiscAnalysis& disc =
            discs[drop7::indexOf(next_row, next_column)];
        if (disc.present) support[count++] = unionReadiness(
            disc.addition, disc.release);
      }
      std::sort(support.begin(), support.begin() + count,
                std::greater<double>());
      if (cell == drop7::kCracked) {
        double inverse = 1;
        for (int offset = 0; offset < count; ++offset) {
          inverse *= 1.0 - support[offset];
        }
        features.cracked_exposure += 1.0 - inverse;
      } else {
        features.solid_exposure +=
            (count > 0 ? support[0] * 0.35 : 0.0) +
            (count > 1 ? support[1] * 0.65 : 0.0);
      }
    }
  }

  const int moves_until_rise =
      std::max(1, std::min(drop7::kMovesPerLevel, state.moves_remaining));
  const double rise_urgency =
      static_cast<double>(drop7::kMovesPerLevel - moves_until_rise) /
      static_cast<double>(drop7::kMovesPerLevel - 1);
  const double projected_occupancy =
      occupied + drop7::kBoardSize - 1.4 * moves_until_rise;
  features.projected_occupancy_debt =
      std::pow(std::max(0.0, projected_occupancy - 14.0), 2.0);
  const double residual_covers =
      std::max(0.0, covers - 1.4 * moves_until_rise);
  features.residual_cover_debt = residual_covers * residual_covers;
  features.imminent_cover_altitude_debt =
      features.cover_altitude_debt * rise_urgency;
  features.peak_height_risk = std::pow(
      std::max(0.0, maximum_height + rise_urgency - 3.0), 3.0);

  std::array<bool, drop7::kBoardSize> low_caps{};
  for (int column = 0; column < drop7::kBoardSize; ++column) {
    const int height = heights[column];
    if (height == 0) continue;
    const std::uint8_t cap =
        board[drop7::indexOf(drop7::kBoardSize - height, column)];
    if (cap != 1 && cap != 2) continue;
    low_caps[column] = true;
    features.low_cap_load +=
        height * height * (cap == 1 ? 1.5 : 1.0);
    if (column > 0 && low_caps[column - 1]) {
      features.adjacent_low_cap_load +=
          std::pow(std::min(heights[column - 1], height), 2.0);
    }
  }

  placementInventory(state, heights, features);

  Board raised{};
  if (drop7::raiseCoveredRow(board, raised)) {
    int popper_count = 0;
    drop7::findPoppers(raised, popper_count);
    const double immediate_rise_weight =
        moves_until_rise == 1 ? 1.0 : readiness(moves_until_rise - 1);
    features.rise_trigger_readiness = popper_count * immediate_rise_weight;
  }
  return features;
}

double phaseUtility(const State& state) {
  if (state.game_over) return -250'000.0;
  const PhaseFeatures f = extractPhaseFeatures(state);
  // This is the release2 + queue2 + altitude2 phase-safety profile. The first
  // block mirrors the existing combined observable evaluator; the second
  // doubles queue and altitude debt, and the release inventory is doubled.
  return
      180.0 * f.open_columns - 10.0 * f.height_load -
      620.0 * f.solid_cells - 220.0 * f.cracked_cells -
      18.0 * f.numbered_cells - 90.0 * f.high_low_numbers +
      140.0 * f.direct_potential + 360.0 * f.latent_chain_potential +
      100.0 * f.cracked_exposure + 40.0 * f.solid_exposure -
      550.0 * f.adjacent_ones - 750.0 * f.triple_twos -
      120.0 * f.dead_low_numbers -
      240.0 * f.projected_occupancy_debt -
      200.0 * f.residual_cover_debt -
      50.0 * f.cover_altitude_debt -
      70.0 * f.imminent_cover_altitude_debt -
      1800.0 * f.peak_height_risk - 120.0 * f.low_cap_load -
      180.0 * f.adjacent_low_cap_load +
      220.0 * f.direct_potential + 300.0 * f.quiet_build_options +
      600.0 * f.quiet_direct_gain +
      600.0 * f.trigger_readiness +
      440.0 * (f.latent_chain_potential + f.cracked_exposure +
               0.35 * f.solid_exposure) +
      1200.0 * f.rise_trigger_readiness;
}

int canonicalTieRank(int column) {
  constexpr std::array<int, drop7::kBoardSize> ranks{{5, 3, 1, 0, 2, 4, 6}};
  return ranks[column];
}

int oneStepMoveCanonical(const State& canonical,
                         const PlannerOptions& options,
                         PlannerStats& stats) {
  int legal_count = 0;
  const auto legal = drop7::legalColumns(canonical.board, legal_count);
  if (legal_count == 0) return -1;
  int best_column = legal[0];
  double best_value = -std::numeric_limits<double>::infinity();
  for (int offset = 0; offset < legal_count; ++offset) {
    const int column = legal[offset];
    double value = 0;
    for (int sample = 0; sample < options.continuation_samples; ++sample) {
      drop7::Mulberry32 random(oneStepSeed(canonical, sample));
      MoveResult move;
      if (!drop7::playMove(canonical, column, random, move)) {
        throw std::runtime_error("one-step policy selected illegal candidate");
      }
      ++stats.simulated_moves;
      ++stats.one_step_moves;
      value += static_cast<double>(move.score_delta) +
               options.leaf_scale * phaseUtility(move.state);
    }
    value /= options.continuation_samples;
    if (value > best_value + 1e-9 ||
        (std::abs(value - best_value) <= 1e-9 &&
         canonicalTieRank(column) < canonicalTieRank(best_column))) {
      best_value = value;
      best_column = column;
    }
  }
  return best_column;
}

int oneStepMove(const State& state, const PlannerOptions& options,
                PlannerStats& stats) {
  bool mirrored = false;
  const State canonical = canonicalState(state, mirrored);
  const int column = oneStepMoveCanonical(canonical, options, stats);
  return mirrored && column >= 0 ? drop7::kBoardSize - 1 - column : column;
}

double tailMean(const std::vector<double>& sorted, double fraction) {
  const int count = std::max(
      1, static_cast<int>(std::ceil(sorted.size() * fraction - 1e-12)));
  return std::accumulate(sorted.begin(), sorted.begin() + count, 0.0) /
         count;
}

void finalizeCandidate(CandidateEvaluation& candidate, RiskMode risk) {
  std::vector<double> sorted = candidate.returns;
  std::sort(sorted.begin(), sorted.end());
  candidate.mean =
      std::accumulate(sorted.begin(), sorted.end(), 0.0) / sorted.size();
  candidate.lower_half = tailMean(sorted, 0.5);
  candidate.cvar25 = tailMean(sorted, 0.25);
  switch (risk) {
    case RiskMode::Mean:
      candidate.utility = candidate.mean;
      break;
    case RiskMode::LowerHalf:
      candidate.utility = candidate.lower_half;
      break;
    case RiskMode::Cvar25:
      candidate.utility = candidate.cvar25;
      break;
    case RiskMode::Blend:
      candidate.utility = 0.65 * candidate.mean + 0.35 * candidate.cvar25;
      break;
  }
}

Decision chooseMoveCanonical(const State& canonical,
                             const PlannerOptions& options,
                             PlannerStats& stats) {
  Decision decision;
  int legal_count = 0;
  const auto legal = drop7::legalColumns(canonical.board, legal_count);
  if (legal_count == 0) return decision;
  const std::uint32_t root_hash = observableHash(canonical);

  for (int offset = 0; offset < legal_count; ++offset) {
    CandidateEvaluation& candidate = decision.candidates[decision.candidate_count++];
    candidate.column = legal[offset];
    candidate.returns.reserve(options.scenarios);
    for (int scenario = 0; scenario < options.scenarios; ++scenario) {
      State simulated = canonical;
      double total = 0;
      bool died = false;
      for (int step = 0; step < options.horizon; ++step) {
        const int column = step == 0
                               ? candidate.column
                               : oneStepMove(simulated, options, stats);
        if (column < 0) {
          died = true;
          total -= options.death_penalty +
                   options.remaining_death_penalty *
                       (options.horizon - step);
          break;
        }
        drop7::Mulberry32 random(rolloutSeed(root_hash, scenario, step));
        MoveResult move;
        if (!drop7::playMove(simulated, column, random, move)) {
          throw std::runtime_error("rollout policy selected an illegal move");
        }
        ++stats.simulated_moves;
        ++stats.root_scenarios;
        total += static_cast<double>(move.score_delta);
        simulated = move.state;
        if (simulated.game_over) {
          died = true;
          total -= options.death_penalty +
                   options.remaining_death_penalty *
                       (options.horizon - step - 1);
          break;
        }
      }
      if (!died) total += options.leaf_scale * phaseUtility(simulated);
      candidate.returns.push_back(total);
    }
    finalizeCandidate(candidate, options.risk);
  }

  const CandidateEvaluation* best = &decision.candidates[0];
  for (int index = 1; index < decision.candidate_count; ++index) {
    const CandidateEvaluation& candidate = decision.candidates[index];
    if (candidate.utility > best->utility + 1e-9 ||
        (std::abs(candidate.utility - best->utility) <= 1e-9 &&
         canonicalTieRank(candidate.column) < canonicalTieRank(best->column))) {
      best = &candidate;
    }
  }
  decision.column = best->column;
  return decision;
}

Decision chooseMove(const State& public_state, const PlannerOptions& options,
                    PlannerStats& stats) {
  // This is the planner's entire interface. There is intentionally no game
  // seed, random tape, callback, or environment RNG parameter.
  bool mirrored = false;
  const State canonical = canonicalState(public_state, mirrored);
  Decision decision = chooseMoveCanonical(canonical, options, stats);
  if (!mirrored || decision.column < 0) return decision;
  decision.column = drop7::kBoardSize - 1 - decision.column;
  for (int index = 0; index < decision.candidate_count; ++index) {
    decision.candidates[index].column =
        drop7::kBoardSize - 1 - decision.candidates[index].column;
  }
  return decision;
}

void validateOptions(const PlannerOptions& options) {
  if (options.horizon < 1 || options.horizon > kMaximumHorizon) {
    throw std::invalid_argument("--horizon must be from 1 to 100");
  }
  if (options.scenarios < 1 || options.scenarios > kMaximumScenarios) {
    throw std::invalid_argument("--scenarios must be from 1 to 64");
  }
  if (options.continuation_samples < 1 ||
      options.continuation_samples > kMaximumContinuationSamples) {
    throw std::invalid_argument("--continuation-samples must be from 1 to 7");
  }
  if (!std::isfinite(options.leaf_scale) || options.leaf_scale <= 0 ||
      !std::isfinite(options.death_penalty) || options.death_penalty <= 0 ||
      !std::isfinite(options.remaining_death_penalty) ||
      options.remaining_death_penalty <= 0) {
    throw std::invalid_argument("planner utility scales must be positive");
  }
}

PlannerOptions parsePlannerOptions(int argc, char** argv) {
  PlannerOptions options;
  options.horizon = parsePositive(
      valueAfter(argc, argv, "--horizon", std::to_string(options.horizon)),
      "--horizon");
  options.scenarios = parsePositive(
      valueAfter(argc, argv, "--scenarios", std::to_string(options.scenarios)),
      "--scenarios");
  options.continuation_samples = parsePositive(
      valueAfter(argc, argv, "--continuation-samples",
                 std::to_string(options.continuation_samples)),
      "--continuation-samples");
  options.risk = parseRisk(valueAfter(argc, argv, "--risk", "blend"));
  options.leaf_scale = parsePositiveDouble(
      valueAfter(argc, argv, "--leaf-scale", std::to_string(options.leaf_scale)),
      "--leaf-scale");
  options.death_penalty = parsePositiveDouble(
      valueAfter(argc, argv, "--death-penalty",
                 std::to_string(options.death_penalty)),
      "--death-penalty");
  options.remaining_death_penalty = parsePositiveDouble(
      valueAfter(argc, argv, "--remaining-death-penalty",
                 std::to_string(options.remaining_death_penalty)),
      "--remaining-death-penalty");
  validateOptions(options);
  return options;
}

RunOptions parseRunOptions(int argc, char** argv) {
  RunOptions options;
  options.games = parsePositive(
      valueAfter(argc, argv, "--games", std::to_string(options.games)),
      "--games");
  options.max_moves = parsePositive(
      valueAfter(argc, argv, "--max-moves", std::to_string(options.max_moves)),
      "--max-moves");
  options.range = valueAfter(argc, argv, "--range", options.range);
  if (options.range == "train") options.seed_start = kTrainingSeedStart;
  else if (options.range == "probe") options.seed_start = kProbeSeedStart;
  else throw std::invalid_argument("--range must be train or probe");
  if (options.games > 256) {
    throw std::invalid_argument("--games is bounded at 256");
  }
  if (options.max_moves > 5000) {
    throw std::invalid_argument("--max-moves is bounded at 5000");
  }
  options.planner = parsePlannerOptions(argc, argv);
  return options;
}

std::uint64_t maximumResidentBytes() {
  rusage usage{};
  if (getrusage(RUSAGE_SELF, &usage) != 0) return 0;
#if defined(__APPLE__)
  return static_cast<std::uint64_t>(usage.ru_maxrss);
#else
  return static_cast<std::uint64_t>(usage.ru_maxrss) * 1024u;
#endif
}

GameResult runGame(std::uint32_t environment_seed, const RunOptions& options) {
  State state = drop7::initialHeadlessState(environment_seed);
  PlannerStats stats;
  while (!state.game_over && state.moves_played < options.max_moves) {
    // Commit to the move before giving the environment seed to the engine.
    const Decision decision = chooseMove(state, options.planner, stats);
    if (decision.column < 0) {
      throw std::runtime_error("planner found no move in a live game");
    }
    MoveResult move;
    if (!drop7::playHeadlessMove(state, environment_seed, decision.column,
                                 move)) {
      throw std::runtime_error("planner committed an illegal move");
    }
  }
  return {environment_seed, state.score, state.moves_played, state.level,
          !state.game_over, stats.simulated_moves};
}

double percentile(std::vector<std::int64_t> values, double quantile) {
  std::sort(values.begin(), values.end());
  if (values.empty()) return 0;
  const double position = quantile * (values.size() - 1);
  const std::size_t lower = static_cast<std::size_t>(std::floor(position));
  const std::size_t upper = static_cast<std::size_t>(std::ceil(position));
  const double fraction = position - lower;
  return values[lower] * (1.0 - fraction) + values[upper] * fraction;
}

void printIntegerArray(const std::vector<GameResult>& results, bool scores) {
  std::cout << '[';
  for (std::size_t index = 0; index < results.size(); ++index) {
    if (index != 0) std::cout << ',';
    std::cout << (scores ? results[index].score : results[index].moves);
  }
  std::cout << ']';
}

int runBenchmark(int argc, char** argv) {
  const RunOptions options = parseRunOptions(argc, argv);
  std::vector<GameResult> results;
  results.reserve(options.games);
  const auto started = Clock::now();
  for (int game = 0; game < options.games; ++game) {
    const std::uint32_t seed =
        options.seed_start + static_cast<std::uint32_t>(game);
    const auto game_started = Clock::now();
    GameResult result = runGame(seed, options);
    results.push_back(result);
    const double seconds =
        std::chrono::duration<double>(Clock::now() - game_started).count();
    std::cout << "GAME {\"seed\":\"0x" << std::hex << std::setw(8)
              << std::setfill('0') << seed << std::dec << std::setfill(' ')
              << "\",\"score\":" << result.score << ",\"moves\":"
              << result.moves << ",\"level\":" << result.level
              << ",\"censored\":" << (result.censored ? "true" : "false")
              << ",\"simulatedMoves\":" << result.simulated_moves
              << ",\"seconds\":" << std::fixed << std::setprecision(6)
              << seconds << "}\n";
  }
  const double seconds =
      std::chrono::duration<double>(Clock::now() - started).count();
  std::vector<std::int64_t> scores;
  scores.reserve(results.size());
  std::int64_t score_sum = 0;
  std::int64_t move_sum = 0;
  std::uint64_t work_sum = 0;
  int censored = 0;
  for (const GameResult& result : results) {
    scores.push_back(result.score);
    score_sum += result.score;
    move_sum += result.moves;
    work_sum += result.simulated_moves;
    if (result.censored) ++censored;
  }
  std::cout << "RESULT {\"range\":\"" << options.range
            << "\",\"seedStart\":\"0x" << std::hex << std::setw(8)
            << std::setfill('0') << options.seed_start << std::dec
            << std::setfill(' ') << "\",\"games\":" << options.games
            << ",\"horizon\":" << options.planner.horizon
            << ",\"scenarios\":" << options.planner.scenarios
            << ",\"continuationSamples\":"
            << options.planner.continuation_samples << ",\"risk\":\""
            << riskName(options.planner.risk) << "\",\"meanScore\":"
            << std::fixed << std::setprecision(3)
            << static_cast<double>(score_sum) / results.size()
            << ",\"medianScore\":" << percentile(scores, 0.5)
            << ",\"p25Score\":" << percentile(scores, 0.25)
            << ",\"minimumScore\":"
            << *std::min_element(scores.begin(), scores.end())
            << ",\"maximumScore\":"
            << *std::max_element(scores.begin(), scores.end())
            << ",\"meanMoves\":"
            << static_cast<double>(move_sum) / results.size()
            << ",\"censored\":" << censored << ",\"seconds\":"
            << seconds << ",\"simulatedMoves\":" << work_sum
            << ",\"simulatedMovesPerSecond\":" << work_sum / seconds
            << ",\"maxRssBytes\":" << maximumResidentBytes()
            << ",\"scores\":";
  printIntegerArray(results, true);
  std::cout << ",\"moves\":";
  printIntegerArray(results, false);
  std::cout << "}\n";
  return 0;
}

State syntheticTestState() {
  State state;
  state.board.fill(drop7::kEmpty);
  state.board[drop7::indexOf(6, 0)] = drop7::kSolid;
  state.board[drop7::indexOf(6, 1)] = drop7::kCracked;
  state.board[drop7::indexOf(5, 1)] = 5;
  state.board[drop7::indexOf(6, 2)] = 6;
  state.board[drop7::indexOf(5, 2)] = 2;
  state.board[drop7::indexOf(6, 3)] = 7;
  state.board[drop7::indexOf(6, 4)] = 4;
  state.next_disc = 3;
  state.moves_remaining = 2;
  return state;
}

bool runSelfTest() {
  PlannerOptions options;
  options.horizon = 3;
  options.scenarios = 3;
  options.continuation_samples = 1;
  validateOptions(options);
  const State state = syntheticTestState();

  PlannerStats first_stats;
  const Decision first = chooseMove(state, options, first_stats);
  PlannerStats repeat_stats;
  const Decision repeat = chooseMove(state, options, repeat_stats);
  if (first.column < 0 || first.column != repeat.column ||
      first_stats.simulated_moves != repeat_stats.simulated_moves) {
    std::cerr << "determinism test failed\n";
    return false;
  }
  for (int index = 0; index < first.candidate_count; ++index) {
    if (first.candidates[index].utility != repeat.candidates[index].utility) {
      std::cerr << "deterministic candidate utility test failed\n";
      return false;
    }
  }

  State altered = state;
  altered.score = 987'654;
  altered.level = 73;
  altered.moves_played = 359;
  PlannerStats altered_stats;
  const Decision seed_blind = chooseMove(altered, options, altered_stats);
  if (seed_blind.column != first.column ||
      altered_stats.simulated_moves != first_stats.simulated_moves) {
    std::cerr << "seed-blind public-state test failed\n";
    return false;
  }
  for (int index = 0; index < first.candidate_count; ++index) {
    if (seed_blind.candidates[index].utility !=
        first.candidates[index].utility) {
      std::cerr << "seed-blind utility test failed\n";
      return false;
    }
  }

  State mirrored = state;
  mirrored.board = mirrorBoard(state.board);
  PlannerStats mirror_stats;
  const Decision mirror_decision = chooseMove(mirrored, options, mirror_stats);
  if (mirror_decision.column != drop7::kBoardSize - 1 - first.column) {
    std::cerr << "mirror-equivariance test failed: " << first.column << " vs "
              << mirror_decision.column << '\n';
    return false;
  }

  bool hash_was_mirrored = false;
  const std::uint32_t hash =
      observableHash(canonicalState(state, hash_was_mirrored));
  for (int step = 0; step < 3; ++step) {
    std::array<bool, 8> strata{};
    for (int scenario = 0; scenario < 7; ++scenario) {
      drop7::Mulberry32 random(rolloutSeed(hash, scenario, step));
      strata[random.nextDisc()] = true;
    }
    for (int disc = 1; disc <= 7; ++disc) {
      if (!strata[disc]) {
        std::cerr << "chance stratification test failed\n";
        return false;
      }
    }
  }

  const std::uint64_t loose_bound =
      static_cast<std::uint64_t>(drop7::kBoardSize) * options.scenarios *
      options.horizon *
      (1u + static_cast<std::uint64_t>(drop7::kBoardSize) *
                options.continuation_samples);
  if (first_stats.simulated_moves == 0 ||
      first_stats.simulated_moves > loose_bound) {
    std::cerr << "bounded-work test failed\n";
    return false;
  }
  if (maximumResidentBytes() == 0) {
    std::cerr << "RSS accounting test failed\n";
    return false;
  }
  std::cout << "SELF_TEST {\"deterministic\":true,\"seedBlind\":true,"
               "\"mirrorEquivariant\":true,\"stratified\":true,"
               "\"boundedWork\":true,\"selectedColumn\":"
            << first.column << ",\"simulatedMoves\":"
            << first_stats.simulated_moves << ",\"maxRssBytes\":"
            << maximumResidentBytes() << "}\n";
  return true;
}

void printUsage() {
  std::cerr
      << "Usage:\n"
      << "  drop7_rollout --self-test\n"
      << "  drop7_rollout --benchmark [--range train|probe] [--games N] "
         "[--max-moves N]\n"
      << "      [--horizon 1..100] [--scenarios 1..64] "
         "[--continuation-samples 1..7]\n"
      << "      [--risk mean|lower|cvar25|blend] [--leaf-scale X] "
         "[--death-penalty X]\n";
}

}  // namespace

int main(int argc, char** argv) {
  try {
    if (hasFlag(argc, argv, "--self-test")) return runSelfTest() ? 0 : 1;
    if (hasFlag(argc, argv, "--benchmark")) return runBenchmark(argc, argv);
    printUsage();
    return 2;
  } catch (const std::exception& error) {
    std::cerr << "error: " << error.what() << '\n';
    return 1;
  }
}