Drop7 Research
approaches/heuristic-search/rollout/teacher.cppC++836 lines · 30.7 KB
#include "../../../src/core/native/engine.hpp"

#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <limits>
#include <numeric>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/resource.h>
#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 kTeacherDomain = 0x5445'4143u;  // "TEAC"
constexpr std::uint32_t kTapeDomain = 0x5441'5045u;     // "TAPE"
constexpr std::uint32_t kTapeRevealDomain = 0x5452'4556u;
constexpr std::uint32_t kTapeDiscDomain = 0x5444'4953u;
constexpr int kMaximumHorizon = 50;
constexpr int kMaximumTapes = 64;
constexpr int kMaximumBeamPerAction = 64;

struct TeacherOptions {
  int horizon = 25;
  int tapes = 14;
  int beam_per_action = 8;
  bool vote_aggregate = false;
  double leaf_scale = 1.0;
  double terminal_penalty = 1'000'000'000.0;
};

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

struct TeacherStats {
  std::uint64_t generated_states = 0;
  std::uint64_t deduplicated_states = 0;
  std::size_t peak_candidates = 0;
  std::size_t peak_retained = 0;
};

struct TeacherDecision {
  int column = -1;
  std::array<double, drop7::kBoardSize> mean_values{};
  std::array<int, drop7::kBoardSize> observations{};
};

struct BeamNode {
  State state{};
  double rank = -std::numeric_limits<double>::infinity();
};

struct GameResult {
  std::uint32_t seed = 0;
  std::int64_t score = 0;
  int moves = 0;
  int level = 1;
  bool censored = false;
  TeacherStats stats{};
  double seconds = 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;
}

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;
}

State canonicalState(const State& state, bool& mirrored) {
  const Board reflected = mirrorBoard(state.board);
  mirrored = std::lexicographical_compare(
      reflected.begin(), reflected.end(), state.board.begin(), state.board.end());
  if (!mirrored) return state;
  State result = state;
  result.board = reflected;
  return result;
}

std::uint32_t observableHash(const State& canonical) {
  // Only mechanics-relevant public state participates. In particular, no
  // environment seed, future tape, score, level, or move index can leak in.
  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 ^ kTeacherDomain);
}

std::uint32_t tapeKey(std::uint32_t root_hash, int tape) {
  return drop7::mix32(
      root_hash ^ kTapeDomain ^
      (static_cast<std::uint32_t>(tape + 1) * 0x9e37'79b9u));
}

std::uint32_t tapeRevealSeed(std::uint32_t tape_key, int ply) {
  return drop7::mix32(
      tape_key ^ kTapeRevealDomain ^
      (static_cast<std::uint32_t>(ply + 1) * 0x85eb'ca6bu));
}

std::uint8_t tapeDisc(std::uint32_t tape_key, int ply) {
  const std::uint32_t bits = drop7::mix32(
      tape_key ^ kTapeDiscDomain ^
      (static_cast<std::uint32_t>(ply + 1) * 0x27d4'eb2du));
  return static_cast<std::uint8_t>(
      ((static_cast<std::uint64_t>(bits) * 7u) >> 32) + 1u);
}

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;
}

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);
}

double observableLeafUtility(const State& state) {
  if (state.game_over) return -250'000.0;
  const Board& board = state.board;
  const auto heights = columnHeights(board);
  int occupied = 0;
  int covers = 0;
  int solid = 0;
  int cracked = 0;
  int numbered = 0;
  int open_columns = 0;
  int high_low = 0;
  int maximum_height = 0;
  double height_load = 0;
  double cover_altitude = 0;
  double direct_potential = 0;
  double low_cap_load = 0;
  double adjacent_low_cap_load = 0;
  std::array<bool, drop7::kBoardSize> low_caps{};

  for (int column = 0; column < drop7::kBoardSize; ++column) {
    if (board[column] == drop7::kEmpty) ++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 std::uint8_t cell = board[drop7::indexOf(row, column)];
      if (cell == drop7::kEmpty) continue;
      ++occupied;
      height_load += elevation * elevation;
      if (cell == drop7::kSolid || cell == drop7::kCracked) {
        ++covers;
        if (cell == drop7::kSolid) ++solid;
        else ++cracked;
        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;
        cover_altitude +=
            elevation * elevation * cover_factor * edge_factor;
        continue;
      }
      if (!drop7::isNumbered(cell)) continue;
      ++numbered;
      if (cell <= 2 && elevation >= 5) ++high_low;
      const int horizontal = drop7::lineLength(board, row, column, false);
      const int vertical = drop7::lineLength(board, row, column, true);
      const double horizontal_ready =
          cell > horizontal ? readiness(static_cast<int>(cell) - horizontal)
                            : 0.0;
      const double vertical_ready =
          cell > vertical ? readiness(static_cast<int>(cell) - vertical) : 0.0;
      direct_potential += unionReadiness(horizontal_ready, vertical_ready);
    }
  }

  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;
    low_cap_load += height * height * (cap == 1 ? 1.5 : 1.0);
    if (column > 0 && low_caps[column - 1]) {
      const int shared = std::min(heights[column - 1], height);
      adjacent_low_cap_load += shared * shared;
    }
  }

  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 = occupied + drop7::kBoardSize -
                           1.4 * moves_until_rise;
  const double occupancy_debt =
      std::pow(std::max(0.0, projected - 14.0), 2.0);
  const double residual_covers =
      std::max(0.0, covers - 1.4 * moves_until_rise);
  const double cover_debt = residual_covers * residual_covers;
  const double peak_risk = std::pow(
      std::max(0.0, maximum_height + rise_urgency - 3.0), 3.0);

  int placement_triggers = 0;
  int quiet_options = 0;
  for (int column = 0; column < drop7::kBoardSize; ++column) {
    if (heights[column] >= drop7::kBoardSize) continue;
    Board placed = board;
    if (!drop7::placeDisc(placed, column, state.next_disc)) continue;
    int popper_count = 0;
    drop7::findPoppers(placed, popper_count);
    if (popper_count > 0) placement_triggers += popper_count;
    else ++quiet_options;
  }

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

  return 180.0 * open_columns - 10.0 * height_load - 620.0 * solid -
         220.0 * cracked - 18.0 * numbered - 90.0 * high_low +
         360.0 * direct_potential - 240.0 * occupancy_debt -
         200.0 * cover_debt - 50.0 * cover_altitude -
         70.0 * cover_altitude * rise_urgency - 1800.0 * peak_risk -
         120.0 * low_cap_load - 180.0 * adjacent_low_cap_load +
         600.0 * placement_triggers + 300.0 * quiet_options +
         1200.0 * rise_trigger_readiness;
}

double rankState(const State& state, const TeacherOptions& options) {
  const double terminal =
      state.game_over ? -options.terminal_penalty : 0.0;
  return static_cast<double>(state.score) +
         options.leaf_scale * observableLeafUtility(state) + terminal;
}

bool playTapeMove(const State& state, int column, std::uint32_t tape_key,
                  int ply, MoveResult& move) {
  drop7::Mulberry32 random(tapeRevealSeed(tape_key, ply));
  if (!drop7::playMove(state, column, random, move)) return false;
  if (!move.state.game_over) move.state.next_disc = tapeDisc(tape_key, ply);
  return true;
}

bool sameDynamics(const State& first, const State& second) {
  return first.board == second.board && first.next_disc == second.next_disc &&
         first.moves_remaining == second.moves_remaining &&
         first.game_over == second.game_over;
}

void insertDeduplicated(std::vector<BeamNode>& candidates, BeamNode node,
                        TeacherStats& stats) {
  for (BeamNode& existing : candidates) {
    if (!sameDynamics(existing.state, node.state)) continue;
    ++stats.deduplicated_states;
    if (node.rank > existing.rank) existing = std::move(node);
    return;
  }
  candidates.push_back(std::move(node));
}

bool betterNode(const BeamNode& first, const BeamNode& second) {
  if (first.rank != second.rank) return first.rank > second.rank;
  if (first.state.score != second.state.score) {
    return first.state.score > second.state.score;
  }
  return drop7::serializeBoard(first.state.board) <
         drop7::serializeBoard(second.state.board);
}

std::array<double, drop7::kBoardSize> evaluateOneTape(
    const State& root, std::uint32_t tape_key,
    const TeacherOptions& options, TeacherStats& stats) {
  std::array<double, drop7::kBoardSize> values{};
  values.fill(-options.terminal_penalty);
  std::array<std::vector<BeamNode>, drop7::kBoardSize> beams;

  int legal_count = 0;
  const auto legal = drop7::legalColumns(root.board, legal_count);
  for (int offset = 0; offset < legal_count; ++offset) {
    const int column = legal[offset];
    MoveResult move;
    if (!playTapeMove(root, column, tape_key, 0, move)) continue;
    ++stats.generated_states;
    beams[column].push_back({move.state, rankState(move.state, options)});
  }

  for (int ply = 1; ply < options.horizon; ++ply) {
    for (int root_column = 0; root_column < drop7::kBoardSize;
         ++root_column) {
      if (beams[root_column].empty()) continue;
      std::vector<BeamNode> candidates;
      candidates.reserve(static_cast<std::size_t>(options.beam_per_action) *
                         drop7::kBoardSize);
      for (const BeamNode& node : beams[root_column]) {
        if (node.state.game_over) {
          insertDeduplicated(candidates, node, stats);
          continue;
        }
        int continuation_count = 0;
        const auto continuations =
            drop7::legalColumns(node.state.board, continuation_count);
        for (int offset = 0; offset < continuation_count; ++offset) {
          MoveResult move;
          if (!playTapeMove(node.state, continuations[offset], tape_key, ply,
                            move)) {
            continue;
          }
          ++stats.generated_states;
          insertDeduplicated(
              candidates,
              {move.state, rankState(move.state, options)}, stats);
        }
      }
      stats.peak_candidates =
          std::max(stats.peak_candidates, candidates.size());
      std::sort(candidates.begin(), candidates.end(), betterNode);
      if (static_cast<int>(candidates.size()) > options.beam_per_action) {
        candidates.resize(options.beam_per_action);
      }
      stats.peak_retained =
          std::max(stats.peak_retained, candidates.size());
      beams[root_column] = std::move(candidates);
    }
  }

  for (int offset = 0; offset < legal_count; ++offset) {
    const int column = legal[offset];
    if (beams[column].empty()) continue;
    const auto best =
        std::max_element(beams[column].begin(), beams[column].end(),
                         [](const BeamNode& first, const BeamNode& second) {
                           return betterNode(second, first);
                         });
    values[column] = best->rank;
  }
  return values;
}

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

TeacherDecision teacherDecisionCanonical(const State& canonical,
                                         const TeacherOptions& options,
                                         TeacherStats& stats) {
  TeacherDecision decision;
  std::array<int, drop7::kBoardSize> votes{};
  State normalized = canonical;
  normalized.score = 0;
  normalized.level = 1;
  normalized.moves_played = 0;
  const std::uint32_t root_hash = observableHash(normalized);
  for (int tape = 0; tape < options.tapes; ++tape) {
    const auto values = evaluateOneTape(
        normalized, tapeKey(root_hash, tape), options, stats);
    int tape_choice = -1;
    double tape_best = -std::numeric_limits<double>::infinity();
    for (int column = 0; column < drop7::kBoardSize; ++column) {
      if (!drop7::isLegal(normalized.board, column)) continue;
      decision.mean_values[column] += values[column];
      ++decision.observations[column];
      if (values[column] > tape_best + 1e-9 ||
          (std::abs(values[column] - tape_best) <= 1e-9 &&
           (tape_choice < 0 || tieRank(column) < tieRank(tape_choice)))) {
        tape_best = values[column];
        tape_choice = column;
      }
    }
    if (tape_choice >= 0) ++votes[tape_choice];
  }

  double best_value = -std::numeric_limits<double>::infinity();
  int best_votes = -1;
  for (int column = 0; column < drop7::kBoardSize; ++column) {
    if (decision.observations[column] == 0) continue;
    decision.mean_values[column] /= decision.observations[column];
    const double value = decision.mean_values[column];
    const bool better_vote = options.vote_aggregate &&
        (votes[column] > best_votes ||
         (votes[column] == best_votes &&
          (value > best_value + 1e-9 ||
           (std::abs(value - best_value) <= 1e-9 &&
            (decision.column < 0 ||
             tieRank(column) < tieRank(decision.column))))));
    const bool better_mean = !options.vote_aggregate &&
        (value > best_value + 1e-9 ||
         (std::abs(value - best_value) <= 1e-9 &&
          (decision.column < 0 || tieRank(column) < tieRank(decision.column))));
    if (better_vote || better_mean) {
      best_votes = votes[column];
      best_value = value;
      decision.column = column;
    }
  }
  return decision;
}

TeacherDecision teacherDecision(const State& public_state,
                                const TeacherOptions& options,
                                TeacherStats& stats) {
  // Entire teacher interface: no environment seed or environment RNG exists.
  bool mirrored = false;
  const State canonical = canonicalState(public_state, mirrored);
  TeacherDecision decision =
      teacherDecisionCanonical(canonical, options, stats);
  if (!mirrored || decision.column < 0) return decision;
  decision.column = drop7::kBoardSize - 1 - decision.column;
  std::array<double, drop7::kBoardSize> mapped_values{};
  std::array<int, drop7::kBoardSize> mapped_observations{};
  for (int column = 0; column < drop7::kBoardSize; ++column) {
    mapped_values[drop7::kBoardSize - 1 - column] =
        decision.mean_values[column];
    mapped_observations[drop7::kBoardSize - 1 - column] =
        decision.observations[column];
  }
  decision.mean_values = mapped_values;
  decision.observations = mapped_observations;
  return decision;
}

void validateTeacherOptions(const TeacherOptions& options) {
  if (options.horizon < 1 || options.horizon > kMaximumHorizon) {
    throw std::invalid_argument("--horizon must be from 1 to 50");
  }
  if (options.tapes < 1 || options.tapes > kMaximumTapes) {
    throw std::invalid_argument("--tapes must be from 1 to 64");
  }
  if (options.beam_per_action < 1 ||
      options.beam_per_action > kMaximumBeamPerAction) {
    throw std::invalid_argument("--beam-per-action must be from 1 to 64");
  }
  if (!std::isfinite(options.leaf_scale) || options.leaf_scale <= 0 ||
      !std::isfinite(options.terminal_penalty) ||
      options.terminal_penalty <= 0) {
    throw std::invalid_argument("teacher utility scales must be positive");
  }
}

TeacherOptions parseTeacherOptions(int argc, char** argv) {
  TeacherOptions options;
  options.vote_aggregate = hasFlag(argc, argv, "--vote-aggregate");
  options.horizon = parsePositive(
      valueAfter(argc, argv, "--horizon", std::to_string(options.horizon)),
      "--horizon");
  options.tapes = parsePositive(
      valueAfter(argc, argv, "--tapes", std::to_string(options.tapes)),
      "--tapes");
  options.beam_per_action = parsePositive(
      valueAfter(argc, argv, "--beam-per-action",
                 std::to_string(options.beam_per_action)),
      "--beam-per-action");
  options.leaf_scale = parsePositiveDouble(
      valueAfter(argc, argv, "--leaf-scale", std::to_string(options.leaf_scale)),
      "--leaf-scale");
  options.terminal_penalty = parsePositiveDouble(
      valueAfter(argc, argv, "--terminal-penalty",
                 std::to_string(options.terminal_penalty)),
      "--terminal-penalty");
  validateTeacherOptions(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 > 64) throw std::invalid_argument("--games is bounded at 64");
  if (options.max_moves > 5000) {
    throw std::invalid_argument("--max-moves is bounded at 5000");
  }
  options.teacher = parseTeacherOptions(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) {
  const auto started = Clock::now();
  State state = drop7::initialHeadlessState(environment_seed);
  TeacherStats stats;
  while (!state.game_over && state.moves_played < options.max_moves) {
    const TeacherDecision decision =
        teacherDecision(state, options.teacher, stats);
    if (decision.column < 0) {
      throw std::runtime_error("teacher found no move in a live game");
    }
    // The actual game seed enters only after the teacher commits its action.
    MoveResult move;
    if (!drop7::playHeadlessMove(state, environment_seed, decision.column,
                                 move)) {
      throw std::runtime_error("teacher committed an illegal move");
    }
  }
  return {environment_seed,
          state.score,
          state.moves_played,
          state.level,
          !state.game_over,
          stats,
          std::chrono::duration<double>(Clock::now() - started).count()};
}

double percentile(std::vector<std::int64_t> values, double quantile) {
  std::sort(values.begin(), values.end());
  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 printArray(const std::vector<GameResult>& results, bool scores) {
  std::cout << '[';
  for (std::size_t index = 0; index < results.size(); ++index) {
    if (index != 0) std::cout << ',';
    if (scores) std::cout << results[index].score;
    else std::cout << results[index].moves;
  }
  std::cout << ']';
}

int runBenchmark(int argc, char** argv) {
  const RunOptions options = parseRunOptions(argc, argv);
  const auto started = Clock::now();
  std::vector<GameResult> results;
  results.reserve(options.games);
  for (int game = 0; game < options.games; ++game) {
    const std::uint32_t seed =
        options.seed_start + static_cast<std::uint32_t>(game);
    GameResult result = runGame(seed, options);
    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")
              << ",\"seconds\":" << std::fixed << std::setprecision(6)
              << result.seconds << ",\"generatedStates\":"
              << result.stats.generated_states << ",\"deduplicatedStates\":"
              << result.stats.deduplicated_states << "}\n";
    results.push_back(result);
  }

  std::int64_t score_sum = 0;
  std::int64_t move_sum = 0;
  std::uint64_t generated_sum = 0;
  std::uint64_t deduplicated_sum = 0;
  std::size_t peak_candidates = 0;
  std::size_t peak_retained = 0;
  int censored = 0;
  std::vector<std::int64_t> scores;
  for (const GameResult& result : results) {
    score_sum += result.score;
    move_sum += result.moves;
    generated_sum += result.stats.generated_states;
    deduplicated_sum += result.stats.deduplicated_states;
    peak_candidates =
        std::max(peak_candidates, result.stats.peak_candidates);
    peak_retained = std::max(peak_retained, result.stats.peak_retained);
    if (result.censored) ++censored;
    scores.push_back(result.score);
  }
  const double seconds =
      std::chrono::duration<double>(Clock::now() - started).count();
  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.teacher.horizon
            << ",\"tapes\":" << options.teacher.tapes
            << ",\"beamPerAction\":" << options.teacher.beam_per_action
            << ",\"aggregation\":\""
            << (options.teacher.vote_aggregate ? "vote" : "mean") << '"'
            << ",\"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 << ",\"generatedStates\":" << generated_sum
            << ",\"statesPerSecond\":" << generated_sum / seconds
            << ",\"deduplicatedStates\":" << deduplicated_sum
            << ",\"peakCandidatesPerAction\":" << peak_candidates
            << ",\"peakRetainedPerAction\":" << peak_retained
            << ",\"maxRssBytes\":" << maximumResidentBytes()
            << ",\"scores\":";
  printArray(results, true);
  std::cout << ",\"moves\":";
  printArray(results, false);
  std::cout << "}\n";
  return 0;
}

State syntheticState() {
  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() {
  TeacherOptions options;
  options.horizon = 4;
  options.tapes = 3;
  options.beam_per_action = 2;
  const State state = syntheticState();
  TeacherStats first_stats;
  const TeacherDecision first = teacherDecision(state, options, first_stats);
  TeacherStats repeat_stats;
  const TeacherDecision repeat = teacherDecision(state, options, repeat_stats);
  if (first.column < 0 || first.column != repeat.column ||
      first.mean_values != repeat.mean_values ||
      first_stats.generated_states != repeat_stats.generated_states) {
    std::cerr << "teacher determinism test failed\n";
    return false;
  }

  State altered = state;
  altered.score = 987'654;
  altered.level = 70;
  altered.moves_played = 345;
  TeacherStats altered_stats;
  const TeacherDecision seed_blind =
      teacherDecision(altered, options, altered_stats);
  if (seed_blind.column != first.column ||
      seed_blind.mean_values != first.mean_values) {
    std::cerr << "teacher seed-blind test failed\n";
    return false;
  }

  State mirrored = state;
  mirrored.board = mirrorBoard(state.board);
  TeacherStats mirror_stats;
  const TeacherDecision reflected =
      teacherDecision(mirrored, options, mirror_stats);
  if (reflected.column != drop7::kBoardSize - 1 - first.column) {
    std::cerr << "teacher mirror test failed\n";
    return false;
  }
  for (int column = 0; column < drop7::kBoardSize; ++column) {
    if (reflected.mean_values[drop7::kBoardSize - 1 - column] !=
        first.mean_values[column]) {
      std::cerr << "teacher mirrored values test failed\n";
      return false;
    }
  }

  TeacherOptions vote_options = options;
  vote_options.vote_aggregate = true;
  TeacherStats vote_stats;
  const TeacherDecision vote =
      teacherDecision(state, vote_options, vote_stats);
  TeacherStats vote_repeat_stats;
  const TeacherDecision vote_repeat =
      teacherDecision(state, vote_options, vote_repeat_stats);
  TeacherStats vote_mirror_stats;
  const TeacherDecision vote_reflected =
      teacherDecision(mirrored, vote_options, vote_mirror_stats);
  TeacherStats vote_altered_stats;
  const TeacherDecision vote_seed_blind =
      teacherDecision(altered, vote_options, vote_altered_stats);
  if (vote.column < 0 || vote.column != vote_repeat.column ||
      vote.column != vote_seed_blind.column ||
      vote_reflected.column != drop7::kBoardSize - 1 - vote.column ||
      vote.mean_values != vote_repeat.mean_values ||
      vote_stats.generated_states != vote_repeat_stats.generated_states) {
    std::cerr << "teacher vote aggregation invariants failed\n";
    return false;
  }

  bool ignored = false;
  const std::uint32_t hash = observableHash(canonicalState(state, ignored));
  const std::uint32_t first_tape = tapeKey(hash, 0);
  const std::uint32_t second_tape = tapeKey(hash, 1);
  if (first_tape == second_tape ||
      (tapeRevealSeed(first_tape, 0) == tapeRevealSeed(second_tape, 0) &&
       tapeDisc(first_tape, 0) == tapeDisc(second_tape, 0))) {
    std::cerr << "independent tape test failed\n";
    return false;
  }
  const std::size_t candidate_bound =
      static_cast<std::size_t>(options.beam_per_action) *
      drop7::kBoardSize;
  if (first_stats.peak_candidates > candidate_bound ||
      first_stats.peak_retained >
          static_cast<std::size_t>(options.beam_per_action)) {
    std::cerr << "teacher memory bound test failed\n";
    return false;
  }
  if (maximumResidentBytes() == 0) {
    std::cerr << "teacher RSS test failed\n";
    return false;
  }
  std::cout << "SELF_TEST {\"deterministic\":true,\"seedBlind\":true,"
               "\"mirrorEquivariant\":true,\"independentTapes\":true,"
               "\"voteAggregation\":true,\"boundedMemory\":true,"
               "\"selectedColumn\":"
            << first.column << ",\"generatedStates\":"
            << first_stats.generated_states << ",\"peakCandidatesPerAction\":"
            << first_stats.peak_candidates << ",\"maxRssBytes\":"
            << maximumResidentBytes() << "}\n";
  return true;
}

void printUsage() {
  std::cerr
      << "Usage:\n"
      << "  drop7_teacher --self-test\n"
      << "  drop7_teacher --benchmark [--range train|probe] [--games N] "
         "[--max-moves N]\n"
      << "      [--horizon 1..50] [--tapes 1..64] "
         "[--beam-per-action 1..64] [--vote-aggregate]\n"
      << "      [--leaf-scale X] [--terminal-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;
  }
}