Drop7 Research
approaches/value-policy-learning/monte-carlo-value/survival-value-scale.cppC++904 lines · 35.3 KB
// Reuses the reflection-safe hashed value model and observable encoding.  The
// embedded CLI is renamed; this executable owns all collection and evaluation
// seeds and does not invoke the embedded pilot entry point.
#define main drop7_mc_value_embedded_cli
#include "mc-value-policy.cpp"
#undef main

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

namespace drop7::survival_scale {

namespace mc = drop7::mc_value;

constexpr int kRootStrata = 5;
constexpr double kRequiredAuc = 0.75;
constexpr double kRequiredRankCorrelation = 0.60;
constexpr double kDefaultSwitchMargin = 8.0;

struct CanonicalState {
  mc::ObservableState state{};
  bool mirrored = false;
};

CanonicalState canonicalizeRoot(const mc::ObservableState& source) {
  CanonicalState result{source, mc::mirrorIsSmaller(source.board)};
  if (!result.mirrored) return result;
  for (int row = 0; row < kBoardSize; ++row) {
    for (int column = 0; column < kBoardSize; ++column) {
      result.state.board[indexOf(row, column)] =
          source.board[indexOf(row, kBoardSize - 1 - column)];
    }
  }
  return result;
}

inline int physicalAction(int canonical_action, bool mirrored) {
  return mirrored ? kBoardSize - 1 - canonical_action : canonical_action;
}

struct RootSuccessor {
  mc::ObservableState state{};
  bool terminal = false;
  std::uint8_t reveal_target = 1;
  std::uint8_t next_disc_target = 1;
};

RootSuccessor rootSuccessorCanonical(const mc::ObservableState& canonical,
                                     int action, int sample) {
  if (sample < 0 || sample >= kRootStrata ||
      !isLegal(canonical.board, action)) {
    throw std::invalid_argument("invalid root successor request");
  }
  const std::uint32_t hash = mc::observableHash(canonical);
  const int reveal_rotation = static_cast<int>(mix32(hash ^ 0x5256'3530u) % 7u);
  const int disc_rotation = static_cast<int>(mix32(hash ^ 0x4453'3530u) % 7u);
  const int stratum = std::min(
      6, static_cast<int>((sample + 0.5) * 7.0 / kRootStrata));
  const auto reveal = static_cast<std::uint8_t>(
      ((reveal_rotation + stratum) % kBoardSize) + 1);
  const auto next_disc = static_cast<std::uint8_t>(
      ((disc_rotation + 3 * stratum) % kBoardSize) + 1);
  const std::uint32_t base = mix32(
      hash ^ (static_cast<std::uint32_t>(sample + 1) * 0xc2b2'ae35u) ^
      0x5356'3530u);
  Mulberry32 random(mc::seedWithFirstDisc(base, reveal));
  MoveResult move;
  if (!playMove(mc::materialize(canonical), action, random, move)) {
    throw std::runtime_error("root successor rejected legal action");
  }
  if (!move.state.game_over) move.state.next_disc = next_disc;
  return {mc::observable(move.state), move.state.game_over, reveal, next_disc};
}

struct ActionEstimate {
  int canonical_action = -1;
  std::array<double, mc::kEnsembleSize> members{};
  double support = 0;
  double disagreement = 0;
};

ActionEstimate evaluateCanonicalAction(const mc::ObservableState& canonical,
                                       int action,
                                       const mc::Ensemble& ensemble) {
  ActionEstimate result;
  result.canonical_action = action;
  int live_predictions = 0;
  for (int sample = 0; sample < kRootStrata; ++sample) {
    const RootSuccessor successor =
        rootSuccessorCanonical(canonical, action, sample);
    for (int member = 0; member < mc::kEnsembleSize; ++member) {
      if (successor.terminal) {
        result.members[member] += 1.0 / kRootStrata;
      } else {
        const mc::Prediction prediction =
            ensemble[member].predict(successor.state);
        result.members[member] +=
            (1.0 + prediction.lifetime) / kRootStrata;
        result.support += prediction.support;
        ++live_predictions;
      }
    }
  }
  if (live_predictions > 0) result.support /= live_predictions;
  const double mean =
      std::accumulate(result.members.begin(), result.members.end(), 0.0) /
      result.members.size();
  double squares = 0;
  for (double value : result.members) {
    squares += (value - mean) * (value - mean);
  }
  result.disagreement =
      std::sqrt(squares / (result.members.size() - 1));
  return result;
}

double auc(const std::vector<double>& predictions,
           const std::vector<int>& labels) {
  if (predictions.size() != labels.size() || predictions.empty()) {
    throw std::invalid_argument("invalid AUC inputs");
  }
  double favorable = 0;
  std::uint64_t pairs = 0;
  for (std::size_t positive = 0; positive < labels.size(); ++positive) {
    if (labels[positive] != 1) continue;
    for (std::size_t negative = 0; negative < labels.size(); ++negative) {
      if (labels[negative] != 0) continue;
      ++pairs;
      if (predictions[positive] > predictions[negative]) favorable += 1;
      if (predictions[positive] == predictions[negative]) favorable += 0.5;
    }
  }
  return pairs == 0 ? 0.5 : favorable / pairs;
}

std::vector<double> ranks(const std::vector<double>& values) {
  std::vector<std::size_t> order(values.size());
  std::iota(order.begin(), order.end(), 0);
  std::sort(order.begin(), order.end(), [&](std::size_t first,
                                             std::size_t second) {
    return values[first] < values[second];
  });
  std::vector<double> result(values.size());
  std::size_t cursor = 0;
  while (cursor < order.size()) {
    std::size_t end = cursor + 1;
    while (end < order.size() && values[order[end]] == values[order[cursor]]) {
      ++end;
    }
    const double rank = (cursor + end - 1) / 2.0;
    for (std::size_t index = cursor; index < end; ++index) {
      result[order[index]] = rank;
    }
    cursor = end;
  }
  return result;
}

double correlation(const std::vector<double>& first,
                   const std::vector<double>& second) {
  if (first.size() != second.size() || first.empty()) {
    throw std::invalid_argument("invalid correlation inputs");
  }
  const double first_mean =
      std::accumulate(first.begin(), first.end(), 0.0) / first.size();
  const double second_mean =
      std::accumulate(second.begin(), second.end(), 0.0) / second.size();
  double covariance = 0;
  double first_variance = 0;
  double second_variance = 0;
  for (std::size_t index = 0; index < first.size(); ++index) {
    const double first_difference = first[index] - first_mean;
    const double second_difference = second[index] - second_mean;
    covariance += first_difference * second_difference;
    first_variance += first_difference * first_difference;
    second_variance += second_difference * second_difference;
  }
  const double denominator = std::sqrt(first_variance * second_variance);
  return denominator == 0 ? 0 : covariance / denominator;
}

double spearman(const std::vector<double>& predictions,
                const std::vector<double>& labels) {
  return correlation(ranks(predictions), ranks(labels));
}

struct Options {
  int trajectories = 64;
  int holdout_games = 16;
  int epochs = 30;
  int max_moves = 500;
  int threads = std::min(8u, std::max(1u, std::thread::hardware_concurrency()));
  int screen_games = 4;
  int confirmation_games = 8;
  float learning_rate = 0.03f;
  double switch_margin = kDefaultSwitchMargin;
  double minimum_support = 8.0;
  double support_ratio = 0.8;
  double maximum_disagreement = 25.0;
};

struct CollectedGame {
  std::uint32_t seed = 0;
  std::int64_t score = 0;
  int moves = 0;
  int clears = 0;
  int reveals = 0;
  bool censored = false;
  std::uint64_t teacher_work = 0;
  std::vector<mc::Label> labels;
};

CollectedGame collectGame(std::uint32_t seed, int max_moves,
                          std::uint32_t game_identifier) {
  State state = initialHeadlessState(seed);
  std::vector<mc::ObservableState> trajectory;
  trajectory.reserve(160);
  CollectedGame result;
  result.seed = seed;
  while (!state.game_over && state.moves_played < max_moves) {
    trajectory.push_back(mc::observable(state));
    cfpi::BehaviorMetrics metrics;
    const int action = cfpi::chooseBehaviorAction(state, {}, &metrics);
    result.teacher_work += metrics.work;
    MoveResult move;
    if (!playHeadlessMove(state, seed, action, move)) {
      throw std::runtime_error("scale collector teacher chose illegal action");
    }
    for (const Wave& wave : move.waves) {
      result.clears += wave.cleared;
      result.reveals += wave.revealed;
    }
  }
  result.score = state.score;
  result.moves = state.moves_played;
  result.censored = !state.game_over;
  if (!result.censored) {
    result.labels.reserve(trajectory.size());
    for (std::size_t index = 0; index < trajectory.size(); ++index) {
      const int remaining =
          state.moves_played - static_cast<int>(index);
      result.labels.push_back({
          trajectory[index],
          static_cast<float>(remaining),
          remaining >= 25 ? 1.0f : 0.0f,
          remaining >= 50 ? 1.0f : 0.0f,
          mix32(game_identifier ^ static_cast<std::uint32_t>(index + 1)),
      });
    }
  }
  return result;
}

std::vector<CollectedGame> collectParallel(const Options& options,
                                           std::uint32_t seed_start) {
  std::vector<CollectedGame> games(options.trajectories);
  std::atomic<int> next{0};
  std::atomic<bool> failed{false};
  std::mutex error_mutex;
  std::string error_message;
  const int worker_count = std::min(options.threads, options.trajectories);
  std::vector<std::thread> workers;
  workers.reserve(worker_count);
  for (int worker = 0; worker < worker_count; ++worker) {
    workers.emplace_back([&]() {
      while (!failed.load(std::memory_order_relaxed)) {
        const int index = next.fetch_add(1, std::memory_order_relaxed);
        if (index >= options.trajectories) break;
        try {
          games[index] = collectGame(
              seed_start + static_cast<std::uint32_t>(index),
              options.max_moves,
              mix32(0x5343'414cu ^ static_cast<std::uint32_t>(index)));
        } catch (const std::exception& error) {
          failed.store(true, std::memory_order_relaxed);
          std::lock_guard<std::mutex> lock(error_mutex);
          error_message = error.what();
        }
      }
    });
  }
  for (std::thread& worker : workers) worker.join();
  if (failed.load()) {
    throw std::runtime_error("parallel collection failed: " + error_message);
  }
  return games;
}

struct PredictionMetrics {
  int examples = 0;
  double mean_label = 0;
  double mean_prediction = 0;
  double lifetime_mae = 0;
  double death_25_auc = 0;
  double death_50_auc = 0;
  double death_25_brier = 0;
  double death_50_brier = 0;
  double death_25_ece = 0;
  double death_50_ece = 0;
  double rank_correlation = 0;
  double mean_support = 0;
  double p10_support = 0;
};

double calibrationError(const std::vector<double>& predictions,
                        const std::vector<int>& labels) {
  constexpr int bins = 10;
  std::array<double, bins> prediction_sum{};
  std::array<double, bins> label_sum{};
  std::array<int, bins> counts{};
  for (std::size_t index = 0; index < predictions.size(); ++index) {
    const int bin = std::min(
        bins - 1, static_cast<int>(std::floor(predictions[index] * bins)));
    prediction_sum[bin] += predictions[index];
    label_sum[bin] += labels[index];
    ++counts[bin];
  }
  double result = 0;
  for (int bin = 0; bin < bins; ++bin) {
    if (counts[bin] == 0) continue;
    result += static_cast<double>(counts[bin]) / predictions.size() *
              std::abs(prediction_sum[bin] / counts[bin] -
                       label_sum[bin] / counts[bin]);
  }
  return result;
}

PredictionMetrics evaluatePredictions(const std::vector<mc::Label>& labels,
                                      const mc::Ensemble& ensemble) {
  if (labels.empty()) throw std::invalid_argument("empty metric labels");
  std::vector<double> lifetime_predictions;
  std::vector<double> lifetimes;
  std::vector<double> death_25_predictions;
  std::vector<double> death_50_predictions;
  std::vector<int> death_25_labels;
  std::vector<int> death_50_labels;
  std::vector<double> supports;
  lifetime_predictions.reserve(labels.size());
  lifetimes.reserve(labels.size());
  death_25_predictions.reserve(labels.size());
  death_50_predictions.reserve(labels.size());
  death_25_labels.reserve(labels.size());
  death_50_labels.reserve(labels.size());
  supports.reserve(labels.size());
  PredictionMetrics result;
  result.examples = static_cast<int>(labels.size());
  for (const mc::Label& label : labels) {
    double lifetime = 0;
    double survival_25 = 0;
    double survival_50 = 0;
    double support = 0;
    for (const mc::ValueModel& member : ensemble) {
      const mc::Prediction prediction = member.predict(label.state);
      lifetime += prediction.lifetime / mc::kEnsembleSize;
      survival_25 += prediction.survival_25 / mc::kEnsembleSize;
      survival_50 += prediction.survival_50 / mc::kEnsembleSize;
      support += prediction.support / mc::kEnsembleSize;
    }
    const double death_25 = 1.0 - survival_25;
    const double death_50 = 1.0 - survival_50;
    const int died_25 = label.lifetime < 25 ? 1 : 0;
    const int died_50 = label.lifetime < 50 ? 1 : 0;
    lifetime_predictions.push_back(lifetime);
    lifetimes.push_back(label.lifetime);
    death_25_predictions.push_back(death_25);
    death_50_predictions.push_back(death_50);
    death_25_labels.push_back(died_25);
    death_50_labels.push_back(died_50);
    supports.push_back(support);
    result.mean_label += label.lifetime;
    result.mean_prediction += lifetime;
    result.lifetime_mae += std::abs(lifetime - label.lifetime);
    result.death_25_brier += (death_25 - died_25) * (death_25 - died_25);
    result.death_50_brier += (death_50 - died_50) * (death_50 - died_50);
    result.mean_support += support;
  }
  const double count = labels.size();
  result.mean_label /= count;
  result.mean_prediction /= count;
  result.lifetime_mae /= count;
  result.death_25_brier /= count;
  result.death_50_brier /= count;
  result.mean_support /= count;
  result.death_25_auc = auc(death_25_predictions, death_25_labels);
  result.death_50_auc = auc(death_50_predictions, death_50_labels);
  result.death_25_ece =
      calibrationError(death_25_predictions, death_25_labels);
  result.death_50_ece =
      calibrationError(death_50_predictions, death_50_labels);
  result.rank_correlation = spearman(lifetime_predictions, lifetimes);
  std::sort(supports.begin(), supports.end());
  result.p10_support = supports[static_cast<std::size_t>(
      std::floor(0.10 * static_cast<double>(supports.size() - 1)))];
  return result;
}

void printPredictionMetrics(std::string_view tag,
                            const PredictionMetrics& metrics) {
  std::cout << std::fixed << std::setprecision(6) << tag
            << " {\"examples\":" << metrics.examples
            << ",\"meanLabel\":" << metrics.mean_label
            << ",\"meanPrediction\":" << metrics.mean_prediction
            << ",\"lifetimeMae\":" << metrics.lifetime_mae
            << ",\"death25Auc\":" << metrics.death_25_auc
            << ",\"death50Auc\":" << metrics.death_50_auc
            << ",\"death25Brier\":" << metrics.death_25_brier
            << ",\"death50Brier\":" << metrics.death_50_brier
            << ",\"death25Ece\":" << metrics.death_25_ece
            << ",\"death50Ece\":" << metrics.death_50_ece
            << ",\"rankCorrelation\":" << metrics.rank_correlation
            << ",\"meanSupport\":" << metrics.mean_support
            << ",\"p10Support\":" << metrics.p10_support << "}\n";
}

struct PolicyCounters {
  std::uint64_t modeled_transitions = 0;
  std::uint64_t teacher_work = 0;
  int switches = 0;
  int support_rejections = 0;
  int agreement_rejections = 0;
};

struct PolicyGame {
  std::uint32_t seed = 0;
  std::int64_t score = 0;
  int moves = 0;
  int clears = 0;
  int reveals = 0;
  PolicyCounters counters{};
};

struct PolicySummary {
  double mean_score = 0;
  double mean_moves = 0;
  double clear_rate = 0;
  double reveal_rate = 0;
  double mean_switches = 0;
  std::uint64_t modeled_transitions = 0;
  std::uint64_t teacher_work = 0;
  int support_rejections = 0;
  int agreement_rejections = 0;
  std::vector<PolicyGame> games;
};

int chooseConservativeAction(const State& state, int behavior_action,
                             const mc::Ensemble& ensemble,
                             const Options& options,
                             PolicyCounters& counters) {
  const CanonicalState canonical = canonicalizeRoot(mc::observable(state));
  const int canonical_behavior =
      canonical.mirrored ? kBoardSize - 1 - behavior_action : behavior_action;
  const ActionEstimate behavior = evaluateCanonicalAction(
      canonical.state, canonical_behavior, ensemble);
  counters.modeled_transitions += kRootStrata;
  int selected = canonical_behavior;
  double best_mean_margin = 0;
  for (int action = 0; action < kBoardSize; ++action) {
    if (action == canonical_behavior || !isLegal(canonical.state.board, action)) {
      continue;
    }
    const ActionEstimate candidate =
        evaluateCanonicalAction(canonical.state, action, ensemble);
    counters.modeled_transitions += kRootStrata;
    const bool supported =
        candidate.support >= options.minimum_support &&
        candidate.support >= options.support_ratio * behavior.support &&
        candidate.disagreement <= options.maximum_disagreement;
    if (!supported) {
      ++counters.support_rejections;
      continue;
    }
    double mean_margin = 0;
    bool members_agree = true;
    for (int member = 0; member < mc::kEnsembleSize; ++member) {
      const double margin =
          candidate.members[member] - behavior.members[member];
      mean_margin += margin / mc::kEnsembleSize;
      if (margin <= options.switch_margin) members_agree = false;
    }
    if (!members_agree) {
      ++counters.agreement_rejections;
      continue;
    }
    if (mean_margin > best_mean_margin) {
      best_mean_margin = mean_margin;
      selected = action;
    }
  }
  if (selected != canonical_behavior) ++counters.switches;
  return physicalAction(selected, canonical.mirrored);
}

PolicyGame runPolicyGame(std::uint32_t seed, const mc::Ensemble* ensemble,
                         const Options& options) {
  State state = initialHeadlessState(seed);
  PolicyGame result;
  result.seed = seed;
  while (!state.game_over && state.moves_played < options.max_moves) {
    cfpi::BehaviorMetrics metrics;
    const int behavior_action =
        cfpi::chooseBehaviorAction(state, {}, &metrics);
    result.counters.teacher_work += metrics.work;
    const int action = ensemble == nullptr
                           ? behavior_action
                           : chooseConservativeAction(
                                 state, behavior_action, *ensemble, options,
                                 result.counters);
    MoveResult move;
    if (!playHeadlessMove(state, seed, action, move)) {
      throw std::runtime_error("scale policy selected illegal action");
    }
    for (const Wave& wave : move.waves) {
      result.clears += wave.cleared;
      result.reveals += wave.revealed;
    }
  }
  result.score = state.score;
  result.moves = state.moves_played;
  return result;
}

PolicySummary summarizePolicies(std::vector<PolicyGame> games) {
  PolicySummary result;
  result.games = std::move(games);
  std::uint64_t moves = 0;
  std::uint64_t clears = 0;
  std::uint64_t reveals = 0;
  for (const PolicyGame& game : result.games) {
    result.mean_score += game.score;
    result.mean_moves += game.moves;
    result.mean_switches += game.counters.switches;
    result.modeled_transitions += game.counters.modeled_transitions;
    result.teacher_work += game.counters.teacher_work;
    result.support_rejections += game.counters.support_rejections;
    result.agreement_rejections += game.counters.agreement_rejections;
    moves += static_cast<std::uint64_t>(game.moves);
    clears += static_cast<std::uint64_t>(game.clears);
    reveals += static_cast<std::uint64_t>(game.reveals);
  }
  const double count = result.games.size();
  result.mean_score /= count;
  result.mean_moves /= count;
  result.mean_switches /= count;
  result.clear_rate = static_cast<double>(clears) / std::max<std::uint64_t>(1, moves);
  result.reveal_rate =
      static_cast<double>(reveals) / std::max<std::uint64_t>(1, moves);
  return result;
}

void printPolicySummary(std::string_view tag, const PolicySummary& summary) {
  std::cout << std::fixed << std::setprecision(3) << tag
            << " {\"games\":" << summary.games.size()
            << ",\"meanScore\":" << summary.mean_score
            << ",\"meanMoves\":" << summary.mean_moves
            << ",\"clearRate\":" << summary.clear_rate
            << ",\"revealRate\":" << summary.reveal_rate
            << ",\"meanSwitches\":" << summary.mean_switches
            << ",\"modeledTransitions\":" << summary.modeled_transitions
            << ",\"supportRejections\":" << summary.support_rejections
            << ",\"agreementRejections\":"
            << summary.agreement_rejections
            << ",\"teacherWork\":" << summary.teacher_work << "}\n";
}

struct PairedResult {
  double score_lower_95 = 0;
  double moves_lower_95 = 0;
};

double pairedLower(const std::vector<double>& differences) {
  const double mean =
      std::accumulate(differences.begin(), differences.end(), 0.0) /
      differences.size();
  if (differences.size() < 2) {
    return -std::numeric_limits<double>::infinity();
  }
  double squares = 0;
  for (double difference : differences) {
    squares += (difference - mean) * (difference - mean);
  }
  const double deviation =
      std::sqrt(squares / (differences.size() - 1));
  return mean - 1.96 * deviation / std::sqrt(differences.size());
}

PairedResult pairedResult(const PolicySummary& behavior,
                          const PolicySummary& candidate) {
  if (behavior.games.size() != candidate.games.size()) {
    throw std::invalid_argument("paired policy summaries differ in size");
  }
  std::vector<double> score_differences;
  std::vector<double> move_differences;
  for (std::size_t index = 0; index < behavior.games.size(); ++index) {
    score_differences.push_back(candidate.games[index].score -
                                behavior.games[index].score);
    move_differences.push_back(candidate.games[index].moves -
                               behavior.games[index].moves);
  }
  return {pairedLower(score_differences), pairedLower(move_differences)};
}

std::uint64_t peakResidentBytes() {
  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
}

int runScale(const Options& options) {
  if (options.trajectories < 64 || options.holdout_games < 8 ||
      options.holdout_games >= options.trajectories || options.epochs < 1 ||
      options.max_moves < 1 || options.threads < 1 || options.screen_games < 4 ||
      options.confirmation_games < 8 || options.learning_rate <= 0 ||
      options.switch_margin < 0 || options.minimum_support < 0 ||
      options.support_ratio < 0 || options.support_ratio > 1 ||
      options.maximum_disagreement <= 0) {
    throw std::invalid_argument("invalid survival scale options");
  }
  const auto started = std::chrono::steady_clock::now();
  constexpr std::uint32_t collection_start = 0x3d70'4000u;
  std::cout << "SURVIVAL_SCALE_CONFIG {\"trajectories\":"
            << options.trajectories
            << ",\"holdoutGames\":" << options.holdout_games
            << ",\"epochs\":" << options.epochs
            << ",\"threads\":" << options.threads
            << ",\"learningRate\":" << options.learning_rate
            << ",\"rootStrata\":" << kRootStrata
            << ",\"requiredAuc\":" << kRequiredAuc
            << ",\"requiredRankCorrelation\":"
            << kRequiredRankCorrelation
            << ",\"switchMargin\":" << options.switch_margin
            << ",\"minimumSupport\":" << options.minimum_support
            << ",\"supportRatio\":" << options.support_ratio
            << ",\"maximumDisagreement\":"
            << options.maximum_disagreement
            << ",\"collectionSeedStart\":" << collection_start
            << ",\"screenSeedStart\":" << 0x3e79'0000u
            << ",\"confirmationSeedStart\":" << 0x3e7a'0000u
            << ",\"seedRanges\":[\"0x3d\",\"0x3e\"]}\n";

  std::vector<CollectedGame> games =
      collectParallel(options, collection_start);
  const int training_games = options.trajectories - options.holdout_games;
  std::vector<mc::Label> training_labels;
  std::vector<mc::Label> holdout_labels;
  std::uint64_t teacher_work = 0;
  int censored = 0;
  double mean_score = 0;
  double mean_moves = 0;
  for (int game = 0; game < options.trajectories; ++game) {
    teacher_work += games[game].teacher_work;
    mean_score += games[game].score;
    mean_moves += games[game].moves;
    censored += games[game].censored ? 1 : 0;
    auto& target = game < training_games ? training_labels : holdout_labels;
    target.insert(target.end(), games[game].labels.begin(),
                  games[game].labels.end());
  }
  mean_score /= options.trajectories;
  mean_moves /= options.trajectories;
  std::cout << std::fixed << std::setprecision(3)
            << "SURVIVAL_SCALE_COLLECTION {\"games\":"
            << options.trajectories
            << ",\"trainingGames\":" << training_games
            << ",\"holdoutGames\":" << options.holdout_games
            << ",\"trainingLabels\":" << training_labels.size()
            << ",\"holdoutLabels\":" << holdout_labels.size()
            << ",\"meanScore\":" << mean_score
            << ",\"meanMoves\":" << mean_moves
            << ",\"censored\":" << censored
            << ",\"teacherWork\":" << teacher_work << "}\n";

  mc::Ensemble ensemble = mc::createEnsemble(0x6c53'1001u);
  for (mc::ValueModel& member : ensemble) {
    for (const mc::Label& label : training_labels) member.observe(label.state);
  }
  for (int epoch = 0; epoch < options.epochs; ++epoch) {
    for (std::size_t offset = 0; offset < training_labels.size(); ++offset) {
      const std::size_t index =
          (offset + static_cast<std::size_t>(epoch) * 7'919u) %
          training_labels.size();
      for (mc::ValueModel& member : ensemble) {
        member.train(training_labels[index], options.learning_rate);
      }
    }
  }
  const PredictionMetrics training_metrics =
      evaluatePredictions(training_labels, ensemble);
  const PredictionMetrics holdout_metrics =
      evaluatePredictions(holdout_labels, ensemble);
  printPredictionMetrics("SURVIVAL_SCALE_TRAIN", training_metrics);
  printPredictionMetrics("SURVIVAL_SCALE_HOLDOUT", holdout_metrics);
  const bool prediction_gate =
      std::min(holdout_metrics.death_25_auc,
               holdout_metrics.death_50_auc) >= kRequiredAuc &&
      holdout_metrics.rank_correlation >= kRequiredRankCorrelation;
  std::cout << "SURVIVAL_SCALE_GATE {\"predictionPassed\":"
            << (prediction_gate ? "true" : "false") << "}\n";
  if (!prediction_gate) {
    const double seconds = std::chrono::duration<double>(
                               std::chrono::steady_clock::now() - started)
                               .count();
    std::cout << "SURVIVAL_SCALE_RESULT {\"qualified\":false,"
                 "\"stoppedAt\":\"prediction\",\"seconds\":"
              << seconds << ",\"peakResidentBytes\":"
              << peakResidentBytes() << "}\n";
    return 3;
  }

  const auto evaluate_pair = [&](std::uint32_t seed_start, int count) {
    std::vector<PolicyGame> behavior_games;
    std::vector<PolicyGame> candidate_games;
    behavior_games.reserve(count);
    candidate_games.reserve(count);
    for (int game = 0; game < count; ++game) {
      const std::uint32_t seed = seed_start + static_cast<std::uint32_t>(game);
      behavior_games.push_back(runPolicyGame(seed, nullptr, options));
      candidate_games.push_back(runPolicyGame(seed, &ensemble, options));
    }
    return std::pair{summarizePolicies(std::move(behavior_games)),
                     summarizePolicies(std::move(candidate_games))};
  };
  auto [screen_behavior, screen_candidate] =
      evaluate_pair(0x3e79'0000u, options.screen_games);
  const PairedResult screen_paired =
      pairedResult(screen_behavior, screen_candidate);
  printPolicySummary("SURVIVAL_SCALE_SCREEN_BEHAVIOR", screen_behavior);
  printPolicySummary("SURVIVAL_SCALE_SCREEN_CANDIDATE", screen_candidate);
  const bool screen_pass =
      screen_candidate.mean_score > screen_behavior.mean_score &&
      screen_candidate.mean_moves > screen_behavior.mean_moves;
  std::cout << "SURVIVAL_SCALE_SCREEN {\"passed\":"
            << (screen_pass ? "true" : "false")
            << ",\"scoreLower95\":" << screen_paired.score_lower_95
            << ",\"movesLower95\":" << screen_paired.moves_lower_95
            << "}\n";
  if (!screen_pass) {
    const double seconds = std::chrono::duration<double>(
                               std::chrono::steady_clock::now() - started)
                               .count();
    std::cout << "SURVIVAL_SCALE_RESULT {\"qualified\":false,"
                 "\"stoppedAt\":\"screen\",\"seconds\":"
              << seconds << ",\"peakResidentBytes\":"
              << peakResidentBytes() << "}\n";
    return 3;
  }

  auto [confirmation_behavior, confirmation_candidate] =
      evaluate_pair(0x3e7a'0000u, options.confirmation_games);
  const PairedResult confirmation_paired =
      pairedResult(confirmation_behavior, confirmation_candidate);
  const bool qualified =
      confirmation_candidate.mean_score > confirmation_behavior.mean_score &&
      confirmation_candidate.mean_moves > confirmation_behavior.mean_moves &&
      confirmation_paired.score_lower_95 > 0 &&
      confirmation_paired.moves_lower_95 > 0;
  printPolicySummary("SURVIVAL_SCALE_CONFIRM_BEHAVIOR",
                     confirmation_behavior);
  printPolicySummary("SURVIVAL_SCALE_CONFIRM_CANDIDATE",
                     confirmation_candidate);
  const double seconds = std::chrono::duration<double>(
                             std::chrono::steady_clock::now() - started)
                             .count();
  std::cout << "SURVIVAL_SCALE_RESULT {\"qualified\":"
            << (qualified ? "true" : "false")
            << ",\"stoppedAt\":\"confirmation\",\"scoreLower95\":"
            << confirmation_paired.score_lower_95
            << ",\"movesLower95\":" << confirmation_paired.moves_lower_95
            << ",\"seconds\":" << seconds
            << ",\"peakResidentBytes\":" << peakResidentBytes() << "}\n";
  return qualified ? 0 : 3;
}

bool selfTest(std::ostream& output) {
  const bool base = mc::selfTest(output);
  const std::vector<double> perfect_predictions{0.1, 0.2, 0.8, 0.9};
  const std::vector<int> binary{0, 0, 1, 1};
  const std::vector<double> ordered{1, 2, 3, 4};
  const bool metrics = std::abs(auc(perfect_predictions, binary) - 1.0) < 1e-12 &&
                       std::abs(spearman(ordered, ordered) - 1.0) < 1e-12;
  State state;
  state.board = initialBoard();
  state.board[indexOf(5, 0)] = 3;
  state.board[indexOf(5, 1)] = 5;
  state.board[indexOf(5, 4)] = 4;
  state.next_disc = 6;
  state.moves_remaining = 3;
  const CanonicalState canonical = canonicalizeRoot(mc::observable(state));
  mc::Ensemble ensemble = mc::createEnsemble(0x6c53'0001u);
  int legal_count = 0;
  const auto legal = legalColumns(canonical.state.board, legal_count);
  std::array<bool, 8> reveal_targets{};
  std::array<bool, 8> disc_targets{};
  bool finite = legal_count > 0;
  for (int offset = 0; offset < legal_count; ++offset) {
    const ActionEstimate estimate =
        evaluateCanonicalAction(canonical.state, legal[offset], ensemble);
    finite = finite && std::isfinite(estimate.members[0]);
  }
  for (int sample = 0; sample < kRootStrata; ++sample) {
    const RootSuccessor successor =
        rootSuccessorCanonical(canonical.state, legal[0], sample);
    reveal_targets[successor.reveal_target] = true;
    disc_targets[successor.next_disc_target] = true;
  }
  const int distinct_reveals = static_cast<int>(std::count(
      reveal_targets.begin() + 1, reveal_targets.end(), true));
  const int distinct_discs = static_cast<int>(
      std::count(disc_targets.begin() + 1, disc_targets.end(), true));
  const bool stratified = distinct_reveals == kRootStrata &&
                          distinct_discs == kRootStrata;
  const bool gates = kRequiredAuc == 0.75 &&
                     kRequiredRankCorrelation == 0.60 &&
                     kDefaultSwitchMargin == 8.0;
  const bool passed = base && metrics && finite && stratified && gates;
  output << "SURVIVAL_SCALE_SELF_TEST {\"passed\":"
         << (passed ? "true" : "false")
         << ",\"metricRules\":" << (metrics ? "true" : "false")
         << ",\"legalActions\":" << legal_count
         << ",\"rootStrata\":" << kRootStrata
         << ",\"distinctRevealTargets\":" << distinct_reveals
         << ",\"distinctDiscTargets\":" << distinct_discs << "}\n";
  return passed;
}

}  // namespace drop7::survival_scale

int main(int argc, char** argv) {
  try {
    if (argc == 2 && std::string_view(argv[1]) == "--self-test") {
      return drop7::survival_scale::selfTest(std::cout) ? EXIT_SUCCESS
                                                        : EXIT_FAILURE;
    }
    const auto value_after = [&](std::string_view flag,
                                 std::string fallback) {
      for (int index = 1; index + 1 < argc; ++index) {
        if (std::string_view(argv[index]) == flag) {
          return std::string(argv[index + 1]);
        }
      }
      return fallback;
    };
    const auto has_flag = [&](std::string_view flag) {
      for (int index = 1; index < argc; ++index) {
        if (std::string_view(argv[index]) == flag) return true;
      }
      return false;
    };
    if (has_flag("--run")) {
      drop7::survival_scale::Options options;
      options.trajectories = std::stoi(value_after(
          "--trajectories", std::to_string(options.trajectories)));
      options.holdout_games = std::stoi(value_after(
          "--holdout-games", std::to_string(options.holdout_games)));
      options.epochs =
          std::stoi(value_after("--epochs", std::to_string(options.epochs)));
      options.max_moves = std::stoi(value_after(
          "--max-moves", std::to_string(options.max_moves)));
      options.threads =
          std::stoi(value_after("--threads", std::to_string(options.threads)));
      options.screen_games = std::stoi(value_after(
          "--screen-games", std::to_string(options.screen_games)));
      options.confirmation_games = std::stoi(value_after(
          "--confirmation-games",
          std::to_string(options.confirmation_games)));
      options.learning_rate = std::stof(value_after(
          "--learning-rate", std::to_string(options.learning_rate)));
      options.switch_margin = std::stod(value_after(
          "--switch-margin", std::to_string(options.switch_margin)));
      options.minimum_support = std::stod(value_after(
          "--minimum-support", std::to_string(options.minimum_support)));
      options.support_ratio = std::stod(value_after(
          "--support-ratio", std::to_string(options.support_ratio)));
      options.maximum_disagreement = std::stod(value_after(
          "--maximum-disagreement",
          std::to_string(options.maximum_disagreement)));
      return drop7::survival_scale::runScale(options);
    }
    std::cerr << "Usage: drop7_survival_value_scale --self-test | --run [options]\n";
    return 2;
  } catch (const std::exception& error) {
    std::cerr << "error: " << error.what() << '\n';
    return 1;
  }
}