Drop7 Research
approaches/terminal-policy-iteration/terminal-rollout/terminal-rollout.cppC++973 lines · 35.1 KB
#include "../../../src/core/native/public-behavior.hpp"

#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <deque>
#include <exception>
#include <future>
#include <iomanip>
#include <iostream>
#include <limits>
#include <numeric>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

namespace {

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

constexpr std::uint32_t kTapeDomain = 0x5441'5045u;  // "TAPE"
constexpr std::uint32_t kTapeRevealDomain = 0x5245'564cu;
constexpr std::uint32_t kTapeDiscDomain = 0x4449'5343u;
constexpr std::uint32_t kScenarioMultiplier = 0x9e37'79b9u;
constexpr std::uint32_t kStepMultiplier = 0x85eb'ca6bu;
constexpr std::uint32_t kEventMultiplier = 0xc2b2'ae35u;
constexpr std::uint32_t kCriticalTrainingSeedStart = 0x3d70'0200u;
constexpr std::array<int, drop7::kBoardSize> kColumnOrder{{
    3, 2, 4, 1, 5, 0, 6,
}};

struct Options {
  int challengers = 2;
  int maximum_scenarios = 8;
  int maximum_moves = 500;
  int audit_games = 4;
  int audit_time_limit_seconds = 115;
  double confidence = 0.99;
  drop7::cfpi::BehaviorOptions behavior;
};

struct ScreenedAction {
  int column = -1;
  double value = -std::numeric_limits<double>::infinity();
};

struct RolloutResult {
  std::int64_t score = 0;
  int moves = 0;
  bool terminal = false;
  std::uint64_t behavior_calls = 0;
  std::uint64_t behavior_work = 0;
};

struct PairedStats {
  int samples = 0;
  double mean_difference = 0;
  double standard_error = std::numeric_limits<double>::infinity();
  double lower_bound = -std::numeric_limits<double>::infinity();
  double minimum_difference = 0;
  double maximum_difference = 0;
  double mean_move_difference = 0;
  bool all_positive = false;
  bool all_terminal = false;
};

struct ChallengerResult {
  int column = -1;
  double screen_value = -std::numeric_limits<double>::infinity();
  std::vector<RolloutResult> rollouts;
  PairedStats paired;
};

struct TeacherResult {
  int baseline_column = -1;
  int selected_column = -1;
  bool switched = false;
  int scenarios = 0;
  int initial_challengers = 0;
  int surviving_challengers = 0;
  std::vector<RolloutResult> baseline_rollouts;
  std::vector<ChallengerResult> challengers;
  std::uint64_t behavior_calls = 0;
  std::uint64_t behavior_work = 0;
  std::uint64_t simulated_moves = 0;
  double elapsed_seconds = 0;
};

struct CriticalState {
  std::uint32_t game_seed = 0;
  int moves_before_death = 0;
  State state;
};

struct CriticalComparison {
  std::uint32_t game_seed = 0;
  int moves_before_death = 0;
  int baseline_column = -1;
  int challenger_column = -1;
  int scenarios = 0;
  double screen_value = -std::numeric_limits<double>::infinity();
  PairedStats paired;
  std::uint64_t behavior_calls = 0;
  std::uint64_t behavior_work = 0;
  std::uint64_t simulated_moves = 0;
  double elapsed_seconds = 0;
};

struct CriticalAuditResult {
  int requested_games = 0;
  int completed_games = 0;
  int terminal_games = 0;
  int collected_states = 0;
  int compared_states = 0;
  int advanced_to_four = 0;
  int positive_mean_score = 0;
  int positive_mean_moves = 0;
  int mean_move_gain_at_least_25 = 0;
  int positive_score_and_positive_moves = 0;
  int positive_score_and_25_moves = 0;
  bool truncated = false;
  std::vector<std::int64_t> game_scores;
  std::vector<int> game_moves;
  std::vector<CriticalComparison> comparisons;
  std::uint64_t behavior_calls = 0;
  std::uint64_t behavior_work = 0;
  std::uint64_t simulated_moves = 0;
  double elapsed_seconds = 0;
};

int tieRank(int column) {
  for (int rank = 0; rank < drop7::kBoardSize; ++rank) {
    if (kColumnOrder[rank] == column) return rank;
  }
  return drop7::kBoardSize;
}

std::uint32_t observableHash(const State& state) {
  std::uint32_t hash = 0x811c'9dc5u;
  for (std::uint8_t cell : state.board) {
    hash ^= static_cast<std::uint32_t>(cell + 1u);
    hash *= 0x0100'0193u;
  }
  hash ^= static_cast<std::uint32_t>(state.next_disc);
  hash *= 0x0100'0193u;
  hash ^= static_cast<std::uint32_t>(state.moves_remaining);
  return drop7::mix32(hash ^ kTapeDomain);
}

std::uint32_t tapeBits(std::uint32_t root_hash, int scenario, int step,
                       int event, std::uint32_t domain) {
  return drop7::mix32(
      root_hash ^ domain ^
      (static_cast<std::uint32_t>(scenario + 1) * kScenarioMultiplier) ^
      (static_cast<std::uint32_t>(step + 1) * kStepMultiplier) ^
      (static_cast<std::uint32_t>(event + 1) * kEventMultiplier));
}

std::uint8_t bitsToDisc(std::uint32_t bits) {
  return static_cast<std::uint8_t>(
      ((static_cast<std::uint64_t>(bits) * drop7::kBoardSize) >> 32) + 1u);
}

struct TapeRandom {
  std::uint32_t root_hash = 0;
  int scenario = 0;
  int step = 0;
  int event = 0;

  std::uint8_t nextDisc() {
    return bitsToDisc(tapeBits(root_hash, scenario, step, event++,
                               kTapeRevealDomain));
  }
};

std::uint8_t nextTapeDisc(std::uint32_t root_hash, int scenario, int step) {
  return bitsToDisc(
      tapeBits(root_hash, scenario, step, 0, kTapeDiscDomain));
}

double screenAction(const State& state, int column,
                    const Options& options) {
  const int samples = options.behavior.chance_samples;
  const std::uint32_t state_seed =
      drop7::cfpi::detail::scenarioSeedForState(
          state, options.behavior.policy_seed, 1);
  double total = 0;
  for (int sample = 0; sample < samples; ++sample) {
    drop7::cfpi::detail::StratifiedRandom random{
        state_seed, sample, samples, 0,
    };
    MoveResult move;
    if (!drop7::cfpi::detail::playMoveSampled(
            state, column, random, move)) {
      total += options.behavior.terminal_utility;
      continue;
    }
    if (move.state.game_over) {
      total += static_cast<double>(move.score_delta) +
               options.behavior.terminal_utility;
      continue;
    }
    move.state.score = 0;
    move.state.next_disc = drop7::cfpi::detail::sampledNextDisc(
        state_seed, sample, samples);
    total += static_cast<double>(move.score_delta) +
             drop7::cfpi::phasePotential(move.state);
  }
  return total / static_cast<double>(samples);
}

std::vector<ScreenedAction> screenChallengers(
    const State& canonical, int baseline, const Options& options) {
  std::vector<ScreenedAction> screened;
  for (int column : kColumnOrder) {
    if (column == baseline || !drop7::isLegal(canonical.board, column)) {
      continue;
    }
    screened.push_back({column, screenAction(canonical, column, options)});
  }
  std::stable_sort(
      screened.begin(), screened.end(),
      [](const ScreenedAction& first, const ScreenedAction& second) {
        if (std::abs(first.value - second.value) > 1e-9) {
          return first.value > second.value;
        }
        return tieRank(first.column) < tieRank(second.column);
      });
  if (static_cast<int>(screened.size()) > options.challengers) {
    screened.resize(options.challengers);
  }
  return screened;
}

RolloutResult rollToTerminal(const State& root, int first_action,
                             std::uint32_t root_hash, int scenario,
                             const Options& options) {
  State state = root;
  const std::int64_t initial_score = state.score;
  const int initial_moves = state.moves_played;
  RolloutResult result;
  int step = 0;
  while (!state.game_over && state.moves_played < options.maximum_moves) {
    int action = first_action;
    if (step > 0) {
      drop7::cfpi::BehaviorMetrics metrics;
      action = drop7::cfpi::chooseBehaviorAction(
          state, options.behavior, &metrics);
      ++result.behavior_calls;
      result.behavior_work += metrics.work;
    }
    if (!drop7::isLegal(state.board, action)) {
      throw std::runtime_error("terminal rollout selected an illegal action");
    }

    TapeRandom random{root_hash, scenario, step, 0};
    MoveResult move;
    if (!drop7::cfpi::detail::playMoveSampled(
            state, action, random, move)) {
      throw std::runtime_error("terminal rollout could not play legal action");
    }
    state = move.state;
    if (!state.game_over) {
      state.next_disc = nextTapeDisc(root_hash, scenario, step);
    }
    ++step;
  }
  result.score = state.score - initial_score;
  result.moves = state.moves_played - initial_moves;
  result.terminal = state.game_over;
  return result;
}

double studentCritical99(int samples) {
  // One-sided 99% Student-t critical values. Only the staged sample sizes used
  // by this feasibility teacher are admitted.
  switch (samples) {
    case 2:
      return 31.821;
    case 4:
      return 4.541;
    case 8:
      return 2.998;
    default:
      throw std::invalid_argument("unsupported confidence sample count");
  }
}

double studentCritical95(int samples) {
  switch (samples) {
    case 2:
      return 6.314;
    case 4:
      return 2.353;
    case 8:
      return 1.895;
    default:
      throw std::invalid_argument("unsupported confidence sample count");
  }
}

PairedStats pairedStats(const std::vector<RolloutResult>& baseline,
                        const std::vector<RolloutResult>& challenger,
                        double confidence) {
  if (baseline.size() != challenger.size() || baseline.size() < 2) {
    throw std::invalid_argument("paired samples must have equal size >= 2");
  }
  PairedStats result;
  result.samples = static_cast<int>(baseline.size());
  result.minimum_difference = std::numeric_limits<double>::infinity();
  result.maximum_difference = -std::numeric_limits<double>::infinity();
  result.all_positive = true;
  result.all_terminal = true;
  std::vector<double> differences;
  differences.reserve(baseline.size());
  for (std::size_t index = 0; index < baseline.size(); ++index) {
    const double difference = static_cast<double>(
        challenger[index].score - baseline[index].score);
    differences.push_back(difference);
    result.mean_difference += difference;
    result.mean_move_difference +=
        challenger[index].moves - baseline[index].moves;
    result.minimum_difference =
        std::min(result.minimum_difference, difference);
    result.maximum_difference =
        std::max(result.maximum_difference, difference);
    result.all_positive = result.all_positive && difference > 0;
    result.all_terminal =
        result.all_terminal && baseline[index].terminal &&
        challenger[index].terminal;
  }
  result.mean_difference /= static_cast<double>(differences.size());
  result.mean_move_difference /= static_cast<double>(differences.size());
  double squared = 0;
  for (double difference : differences) {
    const double residual = difference - result.mean_difference;
    squared += residual * residual;
  }
  const double variance =
      squared / static_cast<double>(differences.size() - 1);
  result.standard_error =
      std::sqrt(variance / static_cast<double>(differences.size()));
  const double critical = confidence >= 0.99
                              ? studentCritical99(result.samples)
                              : studentCritical95(result.samples);
  result.lower_bound =
      result.mean_difference - critical * result.standard_error;
  return result;
}

void accumulateWork(TeacherResult& result,
                    const RolloutResult& rollout) {
  result.behavior_calls += rollout.behavior_calls;
  result.behavior_work += rollout.behavior_work;
  result.simulated_moves += static_cast<std::uint64_t>(rollout.moves);
}

void evaluateNewScenarios(const State& canonical, std::uint32_t root_hash,
                          int baseline, int previous, int target,
                          std::vector<int> active,
                          const Options& options, TeacherResult& result) {
  struct Pending {
    bool baseline = false;
    int challenger_index = -1;
    int scenario = 0;
    std::future<RolloutResult> future;
  };
  std::vector<Pending> pending;
  pending.reserve(
      static_cast<std::size_t>((target - previous) * (active.size() + 1)));
  for (int scenario = previous; scenario < target; ++scenario) {
    pending.push_back({
        true,
        -1,
        scenario,
        std::async(std::launch::async, [&, scenario] {
          return rollToTerminal(canonical, baseline, root_hash, scenario,
                                options);
        }),
    });
    for (int challenger_index : active) {
      const int action = result.challengers[challenger_index].column;
      pending.push_back({
          false,
          challenger_index,
          scenario,
          std::async(std::launch::async, [&, scenario, action] {
            return rollToTerminal(canonical, action, root_hash, scenario,
                                  options);
          }),
      });
    }
  }

  for (Pending& item : pending) {
    RolloutResult rollout = item.future.get();
    accumulateWork(result, rollout);
    if (item.baseline) {
      result.baseline_rollouts.push_back(rollout);
    } else {
      result.challengers[item.challenger_index].rollouts.push_back(rollout);
    }
  }
}

TeacherResult evaluateTeacher(const State& input, const Options& options) {
  if (input.game_over) return {};
  const auto started = Clock::now();
  bool mirrored = false;
  const State canonical =
      drop7::cfpi::detail::canonicalState(input, mirrored);
  drop7::cfpi::BehaviorMetrics root_metrics;
  const int baseline = drop7::cfpi::chooseBehaviorAction(
      canonical, options.behavior, &root_metrics);
  if (baseline < 0) throw std::runtime_error("behavior policy found no action");

  TeacherResult result;
  result.baseline_column = baseline;
  result.selected_column = baseline;
  result.behavior_calls = 1;
  result.behavior_work = root_metrics.work;
  const auto screened =
      screenChallengers(canonical, baseline, options);
  result.initial_challengers = static_cast<int>(screened.size());
  for (const ScreenedAction& candidate : screened) {
    result.challengers.push_back(
        {candidate.column, candidate.value, {}, {}});
  }

  std::vector<int> active;
  for (int index = 0;
       index < static_cast<int>(result.challengers.size()); ++index) {
    active.push_back(index);
  }
  const std::uint32_t root_hash = observableHash(canonical);
  int previous = 0;
  for (int target : {2, 4, 8}) {
    if (target > options.maximum_scenarios || active.empty()) break;
    evaluateNewScenarios(canonical, root_hash, baseline, previous, target,
                         active, options, result);
    result.scenarios = target;
    std::vector<int> survivors;
    for (int challenger_index : active) {
      ChallengerResult& challenger = result.challengers[challenger_index];
      challenger.paired = pairedStats(
          result.baseline_rollouts, challenger.rollouts, options.confidence);
      // The two-scenario look additionally requires both paired outcomes to be
      // positive. This rejects noisy lifetime wins before spending four or
      // eight scenarios on them.
      if (challenger.paired.all_terminal &&
          challenger.paired.lower_bound > 0 &&
          (target > 2 || challenger.paired.all_positive)) {
        survivors.push_back(challenger_index);
      }
    }
    active = std::move(survivors);
    previous = target;
  }

  result.surviving_challengers = static_cast<int>(active.size());
  if (result.scenarios == options.maximum_scenarios && !active.empty()) {
    const int best = *std::max_element(
        active.begin(), active.end(), [&](int first, int second) {
          const auto& first_stats = result.challengers[first].paired;
          const auto& second_stats = result.challengers[second].paired;
          if (std::abs(first_stats.lower_bound -
                       second_stats.lower_bound) > 1e-9) {
            return first_stats.lower_bound < second_stats.lower_bound;
          }
          return tieRank(result.challengers[first].column) >
                 tieRank(result.challengers[second].column);
        });
    result.selected_column = result.challengers[best].column;
    result.switched = true;
  }

  if (mirrored) {
    result.baseline_column =
        drop7::kBoardSize - 1 - result.baseline_column;
    result.selected_column =
        drop7::kBoardSize - 1 - result.selected_column;
    for (ChallengerResult& challenger : result.challengers) {
      challenger.column = drop7::kBoardSize - 1 - challenger.column;
    }
  }
  result.elapsed_seconds =
      std::chrono::duration<double>(Clock::now() - started).count();
  return result;
}

std::vector<CriticalState> collectCriticalStates(
    const Options& options, CriticalAuditResult& audit,
    Clock::time_point deadline) {
  constexpr std::array<int, 3> offsets{{15, 30, 45}};
  std::vector<CriticalState> collected;
  for (int game = 0; game < options.audit_games; ++game) {
    if (Clock::now() >= deadline) {
      audit.truncated = true;
      break;
    }
    const std::uint32_t seed =
        kCriticalTrainingSeedStart + static_cast<std::uint32_t>(game);
    State state = drop7::initialHeadlessState(seed);
    std::vector<State> trajectory;
    trajectory.reserve(static_cast<std::size_t>(options.maximum_moves));
    while (!state.game_over && state.moves_played < options.maximum_moves) {
      trajectory.push_back(state);
      drop7::cfpi::BehaviorMetrics metrics;
      const int action = drop7::cfpi::chooseBehaviorAction(
          state, options.behavior, &metrics);
      ++audit.behavior_calls;
      audit.behavior_work += metrics.work;
      MoveResult move;
      if (!drop7::playHeadlessMove(state, seed, action, move)) {
        throw std::runtime_error(
            "critical audit behavior selected illegal action");
      }
    }
    ++audit.completed_games;
    audit.game_scores.push_back(state.score);
    audit.game_moves.push_back(state.moves_played);
    if (!state.game_over) continue;
    ++audit.terminal_games;
    const int count = static_cast<int>(trajectory.size());
    for (int offset : offsets) {
      if (offset > count) continue;
      collected.push_back({
          seed,
          offset,
          trajectory[static_cast<std::size_t>(count - offset)],
      });
    }
  }
  audit.collected_states = static_cast<int>(collected.size());
  return collected;
}

void appendCriticalRollouts(
    const State& canonical, int baseline, int challenger,
    std::uint32_t root_hash, int previous, int target,
    const Options& options, std::vector<RolloutResult>& baseline_rollouts,
    std::vector<RolloutResult>& challenger_rollouts,
    CriticalComparison& comparison) {
  struct Pair {
    int scenario = 0;
    std::future<RolloutResult> baseline;
    std::future<RolloutResult> challenger;
  };
  std::vector<Pair> pending;
  pending.reserve(static_cast<std::size_t>(target - previous));
  for (int scenario = previous; scenario < target; ++scenario) {
    pending.push_back({
        scenario,
        std::async(std::launch::async, [&, scenario] {
          return rollToTerminal(canonical, baseline, root_hash, scenario,
                                options);
        }),
        std::async(std::launch::async, [&, scenario] {
          return rollToTerminal(canonical, challenger, root_hash, scenario,
                                options);
        }),
    });
  }
  for (Pair& pair : pending) {
    RolloutResult baseline_result = pair.baseline.get();
    RolloutResult challenger_result = pair.challenger.get();
    comparison.behavior_calls += baseline_result.behavior_calls +
                                 challenger_result.behavior_calls;
    comparison.behavior_work += baseline_result.behavior_work +
                                challenger_result.behavior_work;
    comparison.simulated_moves +=
        static_cast<std::uint64_t>(baseline_result.moves +
                                   challenger_result.moves);
    baseline_rollouts.push_back(baseline_result);
    challenger_rollouts.push_back(challenger_result);
  }
}

CriticalComparison compareCriticalState(
    const CriticalState& critical, const Options& options,
    Clock::time_point deadline) {
  const auto started = Clock::now();
  bool mirrored = false;
  const State canonical =
      drop7::cfpi::detail::canonicalState(critical.state, mirrored);
  drop7::cfpi::BehaviorMetrics root_metrics;
  const int baseline = drop7::cfpi::chooseBehaviorAction(
      canonical, options.behavior, &root_metrics);
  Options screening_options = options;
  screening_options.challengers = 1;
  const auto screened =
      screenChallengers(canonical, baseline, screening_options);

  CriticalComparison result;
  result.game_seed = critical.game_seed;
  result.moves_before_death = critical.moves_before_death;
  result.baseline_column = baseline;
  result.behavior_calls = 1;
  result.behavior_work = root_metrics.work;
  if (screened.empty()) {
    result.challenger_column = -1;
    result.elapsed_seconds =
        std::chrono::duration<double>(Clock::now() - started).count();
    return result;
  }
  result.challenger_column = screened.front().column;
  result.screen_value = screened.front().value;

  std::vector<RolloutResult> baseline_rollouts;
  std::vector<RolloutResult> challenger_rollouts;
  const std::uint32_t root_hash = observableHash(canonical);
  appendCriticalRollouts(
      canonical, baseline, result.challenger_column, root_hash, 0, 2,
      options, baseline_rollouts, challenger_rollouts, result);
  result.scenarios = 2;
  const bool both_favor_challenger =
      challenger_rollouts[0].score > baseline_rollouts[0].score &&
      challenger_rollouts[1].score > baseline_rollouts[1].score;
  if (both_favor_challenger && Clock::now() < deadline) {
    appendCriticalRollouts(
        canonical, baseline, result.challenger_column, root_hash, 2, 4,
        options, baseline_rollouts, challenger_rollouts, result);
    result.scenarios = 4;
  }
  result.paired = pairedStats(
      baseline_rollouts, challenger_rollouts, options.confidence);
  if (mirrored) {
    result.baseline_column =
        drop7::kBoardSize - 1 - result.baseline_column;
    result.challenger_column =
        drop7::kBoardSize - 1 - result.challenger_column;
  }
  result.elapsed_seconds =
      std::chrono::duration<double>(Clock::now() - started).count();
  return result;
}

CriticalAuditResult runCriticalAudit(const Options& options) {
  const auto started = Clock::now();
  const auto deadline =
      started + std::chrono::seconds(options.audit_time_limit_seconds);
  CriticalAuditResult result;
  result.requested_games = options.audit_games;
  const std::vector<CriticalState> states =
      collectCriticalStates(options, result, deadline);
  for (const CriticalState& state : states) {
    if (Clock::now() >= deadline) {
      result.truncated = true;
      break;
    }
    CriticalComparison comparison =
        compareCriticalState(state, options, deadline);
    if (comparison.challenger_column < 0) continue;
    ++result.compared_states;
    if (comparison.scenarios == 4) ++result.advanced_to_four;
    if (comparison.paired.all_terminal &&
        comparison.paired.mean_difference > 0) {
      ++result.positive_mean_score;
    }
    if (comparison.paired.all_terminal &&
        comparison.paired.mean_move_difference > 0) {
      ++result.positive_mean_moves;
    }
    if (comparison.paired.all_terminal &&
        comparison.paired.mean_move_difference >= 25) {
      ++result.mean_move_gain_at_least_25;
    }
    if (comparison.paired.all_terminal &&
        comparison.paired.mean_difference > 0 &&
        comparison.paired.mean_move_difference > 0) {
      ++result.positive_score_and_positive_moves;
    }
    if (comparison.paired.all_terminal &&
        comparison.paired.mean_difference > 0 &&
        comparison.paired.mean_move_difference >= 25) {
      ++result.positive_score_and_25_moves;
    }
    result.behavior_calls += comparison.behavior_calls;
    result.behavior_work += comparison.behavior_work;
    result.simulated_moves += comparison.simulated_moves;
    result.comparisons.push_back(std::move(comparison));
  }
  result.elapsed_seconds =
      std::chrono::duration<double>(Clock::now() - started).count();
  return result;
}

void printCriticalAudit(const CriticalAuditResult& result) {
  const double mean_score =
      result.game_scores.empty()
          ? 0
          : static_cast<double>(std::accumulate(
                result.game_scores.begin(), result.game_scores.end(),
                std::int64_t{0})) /
                static_cast<double>(result.game_scores.size());
  const double mean_moves =
      result.game_moves.empty()
          ? 0
          : static_cast<double>(std::accumulate(
                result.game_moves.begin(), result.game_moves.end(), 0)) /
                static_cast<double>(result.game_moves.size());
  std::cout << std::fixed << std::setprecision(3)
            << "{\"mode\":\"critical-audit\""
            << ",\"training_seed_start\":\"0x3d700200\""
            << ",\"requested_games\":" << result.requested_games
            << ",\"completed_games\":" << result.completed_games
            << ",\"terminal_games\":" << result.terminal_games
            << ",\"baseline_mean_score\":" << mean_score
            << ",\"baseline_mean_moves\":" << mean_moves
            << ",\"audit_offsets\":[15,30,45]"
            << ",\"game_scores\":[";
  for (std::size_t index = 0; index < result.game_scores.size(); ++index) {
    if (index > 0) std::cout << ',';
    std::cout << result.game_scores[index];
  }
  std::cout << "],\"game_moves\":[";
  for (std::size_t index = 0; index < result.game_moves.size(); ++index) {
    if (index > 0) std::cout << ',';
    std::cout << result.game_moves[index];
  }
  std::cout << ']'
            << ",\"collected_states\":" << result.collected_states
            << ",\"compared_states\":" << result.compared_states
            << ",\"advanced_to_four\":" << result.advanced_to_four
            << ",\"positive_mean_score\":"
            << result.positive_mean_score
            << ",\"positive_mean_moves\":"
            << result.positive_mean_moves
            << ",\"mean_move_gain_at_least_25\":"
            << result.mean_move_gain_at_least_25
            << ",\"positive_score_and_positive_moves\":"
            << result.positive_score_and_positive_moves
            << ",\"positive_score_and_25_moves\":"
            << result.positive_score_and_25_moves
            << ",\"truncated\":"
            << (result.truncated ? "true" : "false")
            << ",\"behavior_calls\":" << result.behavior_calls
            << ",\"behavior_work\":" << result.behavior_work
            << ",\"simulated_moves\":" << result.simulated_moves
            << ",\"elapsed_seconds\":" << result.elapsed_seconds
            << ",\"comparisons\":[";
  for (std::size_t index = 0; index < result.comparisons.size(); ++index) {
    if (index > 0) std::cout << ',';
    const CriticalComparison& comparison = result.comparisons[index];
    std::cout << "{\"seed\":\"0x" << std::hex
              << comparison.game_seed << std::dec << "\""
              << ",\"moves_before_death\":"
              << comparison.moves_before_death
              << ",\"baseline\":" << comparison.baseline_column
              << ",\"challenger\":" << comparison.challenger_column
              << ",\"scenarios\":" << comparison.scenarios
              << ",\"screen_value\":" << comparison.screen_value
              << ",\"mean_score_difference\":"
              << comparison.paired.mean_difference
              << ",\"mean_move_difference\":"
              << comparison.paired.mean_move_difference
              << ",\"minimum_score_difference\":"
              << comparison.paired.minimum_difference
              << ",\"maximum_score_difference\":"
              << comparison.paired.maximum_difference
              << ",\"lower_bound\":"
              << comparison.paired.lower_bound
              << ",\"all_terminal\":"
              << (comparison.paired.all_terminal ? "true" : "false")
              << ",\"elapsed_seconds\":"
              << comparison.elapsed_seconds << '}';
  }
  std::cout << "]}\n";
}

void printResult(const TeacherResult& result) {
  std::cout << std::fixed << std::setprecision(3)
            << "{\"baseline\":" << result.baseline_column
            << ",\"selected\":" << result.selected_column
            << ",\"switched\":" << (result.switched ? "true" : "false")
            << ",\"scenarios\":" << result.scenarios
            << ",\"initial_challengers\":"
            << result.initial_challengers
            << ",\"surviving_challengers\":"
            << result.surviving_challengers
            << ",\"behavior_calls\":" << result.behavior_calls
            << ",\"behavior_work\":" << result.behavior_work
            << ",\"simulated_moves\":" << result.simulated_moves
            << ",\"elapsed_seconds\":" << result.elapsed_seconds
            << ",\"rollout_moves_per_second\":"
            << (result.elapsed_seconds > 0
                    ? result.simulated_moves / result.elapsed_seconds
                    : 0)
            << ",\"challengers\":[";
  for (std::size_t index = 0; index < result.challengers.size(); ++index) {
    if (index > 0) std::cout << ',';
    const ChallengerResult& challenger = result.challengers[index];
    std::cout << "{\"column\":" << challenger.column
              << ",\"screen_value\":" << challenger.screen_value
              << ",\"samples\":" << challenger.paired.samples
              << ",\"mean_difference\":"
              << challenger.paired.mean_difference
              << ",\"standard_error\":"
              << challenger.paired.standard_error
              << ",\"lower_bound\":"
              << challenger.paired.lower_bound
              << ",\"minimum_difference\":"
              << challenger.paired.minimum_difference
              << ",\"maximum_difference\":"
              << challenger.paired.maximum_difference
              << ",\"mean_move_difference\":"
              << challenger.paired.mean_move_difference
              << ",\"all_terminal\":"
              << (challenger.paired.all_terminal ? "true" : "false")
              << '}';
  }
  std::cout << "]}\n";
}

bool selfTest() {
  Options options;
  options.maximum_scenarios = 2;
  State state;
  state.board.fill(drop7::kEmpty);
  for (int row = 1; row < drop7::kBoardSize; ++row) {
    for (int column = 0; column < drop7::kBoardSize; ++column) {
      state.board[drop7::indexOf(row, column)] = drop7::kSolid;
    }
  }
  state.next_disc = 6;
  state.moves_remaining = 1;

  const TeacherResult first = evaluateTeacher(state, options);
  const TeacherResult second = evaluateTeacher(state, options);
  const bool deterministic =
      first.baseline_column == second.baseline_column &&
      first.selected_column == second.selected_column &&
      first.scenarios == second.scenarios &&
      first.challengers.size() == second.challengers.size();
  const bool legal =
      drop7::isLegal(state.board, first.selected_column);
  const bool retained =
      !first.switched && first.selected_column == first.baseline_column;
  const bool terminal =
      std::all_of(first.baseline_rollouts.begin(),
                  first.baseline_rollouts.end(),
                  [](const RolloutResult& rollout) {
                    return rollout.terminal && rollout.moves == 1;
                  });
  bool paired_equal = true;
  for (const ChallengerResult& challenger : first.challengers) {
    paired_equal =
        paired_equal && challenger.paired.mean_difference == 0 &&
        challenger.paired.lower_bound == 0;
  }

  State reflected = state;
  reflected.board =
      drop7::cfpi::detail::mirrorBoard(state.board);
  const TeacherResult mirror = evaluateTeacher(reflected, options);
  const bool mirror_safe =
      mirror.selected_column ==
      drop7::kBoardSize - 1 - first.selected_column;
  const bool passed = deterministic && legal && retained && terminal &&
                      paired_equal && mirror_safe;
  std::cout << "{\"deterministic\":"
            << (deterministic ? "true" : "false")
            << ",\"legal\":" << (legal ? "true" : "false")
            << ",\"retained\":" << (retained ? "true" : "false")
            << ",\"terminal\":" << (terminal ? "true" : "false")
            << ",\"paired_equal\":"
            << (paired_equal ? "true" : "false")
            << ",\"mirror_safe\":"
            << (mirror_safe ? "true" : "false")
            << ",\"passed\":" << (passed ? "true" : "false")
            << "}\n";
  return passed;
}

int parsePositive(std::string_view value, std::string_view name) {
  std::size_t consumed = 0;
  const long long parsed = std::stoll(std::string(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);
}

Options parseOptions(int argc, char** argv) {
  Options options;
  for (int index = 1; index < argc; ++index) {
    const std::string_view argument = argv[index];
    if (argument == "--self-test" || argument == "--benchmark" ||
        argument == "--critical-audit") {
      continue;
    }
    if (index + 1 >= argc) {
      throw std::invalid_argument(std::string(argument) + " requires a value");
    }
    const std::string_view value = argv[++index];
    if (argument == "--challengers") {
      options.challengers = parsePositive(value, argument);
      if (options.challengers > 2) {
        throw std::invalid_argument("--challengers must be 1 or 2");
      }
    } else if (argument == "--max-scenarios") {
      options.maximum_scenarios = parsePositive(value, argument);
      if (options.maximum_scenarios != 2 &&
          options.maximum_scenarios != 4 &&
          options.maximum_scenarios != 8) {
        throw std::invalid_argument(
            "--max-scenarios must be 2, 4, or 8");
      }
    } else if (argument == "--max-moves") {
      options.maximum_moves = parsePositive(value, argument);
    } else if (argument == "--games") {
      options.audit_games = parsePositive(value, argument);
      if (options.audit_games > 4) {
        throw std::invalid_argument("--games must be from 1 to 4");
      }
    } else if (argument == "--time-limit-seconds") {
      options.audit_time_limit_seconds = parsePositive(value, argument);
      if (options.audit_time_limit_seconds > 120) {
        throw std::invalid_argument(
            "--time-limit-seconds must be from 1 to 120");
      }
    } else if (argument == "--confidence") {
      options.confidence = std::stod(std::string(value));
      if (options.confidence != 0.95 && options.confidence != 0.99) {
        throw std::invalid_argument("--confidence must be 0.95 or 0.99");
      }
    } else {
      throw std::invalid_argument("unknown argument " +
                                  std::string(argument));
    }
  }
  return options;
}

}  // namespace

int main(int argc, char** argv) {
  try {
    bool run_self_test = false;
    bool run_critical_audit = false;
    for (int index = 1; index < argc; ++index) {
      if (std::string_view(argv[index]) == "--self-test") {
        run_self_test = true;
      } else if (std::string_view(argv[index]) == "--critical-audit") {
        run_critical_audit = true;
      }
    }
    if (run_self_test) return selfTest() ? 0 : 1;

    const Options options = parseOptions(argc, argv);
    if (run_critical_audit) {
      printCriticalAudit(runCriticalAudit(options));
      return 0;
    }
    State observable;
    observable.board = drop7::initialBoard();
    observable.next_disc = 4;
    observable.moves_remaining = drop7::kMovesPerLevel;
    const TeacherResult result = evaluateTeacher(observable, options);
    printResult(result);
    return 0;
  } catch (const std::exception& error) {
    std::cerr << "drop7_terminal_rollout: " << error.what() << '\n';
    return 2;
  }
}