Drop7 Research
approaches/fair-expectimax/transition-rewards/fair-phase-energy-release.cppC++959 lines · 37.4 KB
#define DROP7_FAIR_ONLY_DEPTH4_LIBRARY
#include "../reference/fair-only-depth4.cpp"
#undef DROP7_FAIR_ONLY_DEPTH4_LIBRARY

#include <atomic>
#include <future>
#include <optional>
#include <sstream>

// Adds separate terms for stored potential near a covered-row rise and for
// releasing numbered discs.  Search topology, chance sampling, terminal
// utility, iterative deepening, and cache limits match the reference
// full-width D4 implementation.
namespace drop7::fair_phase_energy_release {

namespace stock = fair_only_depth4;
namespace frozen = fair_only_horizon;
using Clock = std::chrono::steady_clock;

constexpr int kDepth = stock::kCandidateDepth;
constexpr int kChanceSamples = stock::kChanceSamples;
constexpr std::uint64_t kMaximumWork = stock::kMaximumWork;
constexpr std::size_t kMaximumCacheEntries = stock::kMaximumCacheEntries;
constexpr std::uint64_t kWorstCaseWork = stock::kWorstCaseD4Work;
constexpr std::uint64_t kWorstCaseCacheEntries =
    stock::kWorstCaseD4CacheEntries;
constexpr int kMaximumMoves = 1'000;
constexpr int kDefaultThreads = 4;
constexpr std::uint32_t kFittingStart = 0x3de5'0000u;
constexpr int kFittingGames = 4;
constexpr std::uint32_t kHeldoutStart = 0x3de6'0000u;
constexpr int kHeldoutGames = 8;
constexpr std::uint32_t kScreenStart = 0x3eb3'0000u;
constexpr int kScreenGames = 8;
constexpr std::uint32_t kConfirmationStart = 0x3eb4'0000u;
constexpr int kConfirmationGames = 16;
constexpr double kMaterialFirstPairClearRatio = 0.95;
constexpr int kMinimumLooStableFolds = 3;
constexpr double kWallLimitSeconds = 35.0 * 60.0;
constexpr double kWallProjectionSafetyFactor = 1.50;
constexpr std::uint64_t kMaximumSelfTestRssBytes = 2ull * 1024u * 1024u * 1024u;

struct Config {
  const char* name;
  double clear_reward;
  double phase_strength;
  double reveal_reward = 0.0;
};

// Frozen before any assigned gameplay.  The phase vector is indexed by
// moves_remaining: it raises direct/latent energy value early in the cycle and
// lowers it as the next covered-row rise approaches.
constexpr std::array<double, kMovesPerLevel + 1> kPhaseEnergyDelta{{
    0.0, -0.40, -0.25, 0.0, 0.20, 0.35,
}};
constexpr std::array<Config, 5> kMenu{{
    {"stock", 0.0, 0.0},
    {"clear-only", 600.0, 0.0},
    {"phase-only", 0.0, 1.0},
    {"combined-moderate", 600.0, 1.0},
    {"combined-aggressive", 1'200.0, 1.5},
}};

static_assert(kDepth == 4 && kChanceSamples == 5);
static_assert(kMaximumWork > kWorstCaseWork);
static_assert(kMaximumCacheEntries > kWorstCaseCacheEntries);
static_assert(kWorstCaseWork == 3'134'950);
static_assert(kWorstCaseCacheEntries == 45'430);
static_assert(kLevelBonus == 7'000);
static_assert(frozen::kPolicySeed == 0xd707'5eedu);
static_assert(frozen::kDirectPotentialWeight == 1'600.0);
static_assert(frozen::kLatentChainPotentialWeight == 700.0);
static_assert(kFittingStart + kFittingGames < kHeldoutStart);
static_assert(kHeldoutStart + kHeldoutGames < kScreenStart);
static_assert(kScreenStart + kScreenGames < kConfirmationStart);
static_assert((kFittingStart >> 24u) == 0x3du &&
              (kHeldoutStart >> 24u) == 0x3du &&
              (kScreenStart >> 24u) == 0x3eu &&
              (kConfirmationStart >> 24u) == 0x3eu);
static_assert((kFittingStart >> 24u) != 0x7du &&
              (kFittingStart >> 24u) != 0xd7u);

std::mutex progress_mutex;

State publicState(const State& source) {
  State result;
  result.board = source.board;
  result.next_disc = source.next_disc;
  result.moves_remaining = source.moves_remaining;
  result.game_over = source.game_over;
  result.score = 0;
  result.level = 1;
  result.moves_played = 0;
  return result;
}

double phaseEnergyAdjustment(const State& state, const Config& config) {
  if (config.phase_strength == 0.0) return 0.0;
  if (state.moves_remaining < 1 || state.moves_remaining > kMovesPerLevel) {
    throw std::invalid_argument("invalid rise phase in energy evaluator");
  }
  const auto features = frozen::extractFairFeatures(state).heuristic;
  const double stored_energy =
      frozen::kDirectPotentialWeight * features.direct_potential +
      frozen::kLatentChainPotentialWeight * features.latent_chain_potential;
  return config.phase_strength * kPhaseEnergyDelta[state.moves_remaining] *
         stored_energy;
}

double candidateLeaf(const State& state, const Config& config) {
  const double stock_value = frozen::fairLeaf(state);
  if (config.phase_strength == 0.0) return stock_value;
  return stock_value + phaseEnergyAdjustment(state, config);
}

int numberedCleared(const MoveResult& move) {
  int result = 0;
  for (const Wave& wave : move.waves) result += wave.cleared;
  return result;
}

int coversRevealed(const MoveResult& move) {
  int result = 0;
  for (const Wave& wave : move.waves) result += wave.revealed;
  return result;
}

double transitionValue(const MoveResult& move, const Config& config) {
  const double score = static_cast<double>(move.score_delta);
  if (config.clear_reward == 0.0 && config.reveal_reward == 0.0) return score;
  return score + config.clear_reward * numberedCleared(move) +
         config.reveal_reward * coversRevealed(move);
}

class WorkLimitReached : public std::exception {};

struct CacheEntry {
  double value = 0.0;
  std::list<std::string>::iterator order;
};

struct SearchContext {
  std::unordered_map<std::string, CacheEntry> cache;
  std::list<std::string> order;
  std::uint64_t nodes = 0;
  std::uint64_t work = 0;
  std::uint64_t cache_hits = 0;
};

void checkBudget(const SearchContext& context) {
  if (context.work >= kMaximumWork) throw WorkLimitReached{};
}

void cacheValue(SearchContext& context, std::string key, double value) {
  const auto prior = context.cache.find(key);
  if (prior != context.cache.end()) {
    context.order.erase(prior->second.order);
    context.cache.erase(prior);
  }
  while (context.cache.size() >= kMaximumCacheEntries) {
    const std::string& oldest = context.order.front();
    context.cache.erase(oldest);
    context.order.pop_front();
  }
  context.order.push_back(key);
  const auto order = std::prev(context.order.end());
  context.cache.emplace(std::move(key), CacheEntry{value, order});
}

double bestFutureValue(const State& state, int depth, const Config& config,
                       SearchContext& context);

struct ActionValue {
  double value = 0.0;
  double expected_score = 0.0;
};

ActionValue evaluateAction(const State& state, int column, int depth,
                           const Config& config, SearchContext& context) {
  const std::uint32_t state_seed = cfpi::detail::scenarioSeedForState(
      state, frozen::kPolicySeed, depth);
  ActionValue result;
  for (int sample = 0; sample < kChanceSamples; ++sample) {
    checkBudget(context);
    cfpi::detail::StratifiedRandom random{
        state_seed, sample, kChanceSamples, 0};
    MoveResult move;
    const bool played =
        cfpi::detail::playMoveSampled(state, column, random, move);
    ++context.work;
    if (!played) {
      result.value += frozen::kTerminalUtility;
      continue;
    }
    const double score_delta = static_cast<double>(move.score_delta);
    const double transition = transitionValue(move, config);
    result.expected_score += score_delta;
    if (move.state.game_over) {
      result.value += transition + frozen::kTerminalUtility;
      continue;
    }
    move.state = publicState(move.state);
    move.state.next_disc = cfpi::detail::sampledNextDisc(
        state_seed, sample, kChanceSamples);
    bool ignored = false;
    const State next = cfpi::detail::canonicalState(move.state, ignored);
    result.value +=
        transition + bestFutureValue(next, depth - 1, config, context);
  }
  result.value /= kChanceSamples;
  result.expected_score /= kChanceSamples;
  return result;
}

double evaluateLeaf(const State& state, const Config& config,
                    SearchContext& context) {
  checkBudget(context);
  ++context.work;
  const double value = candidateLeaf(state, config);
  if (!std::isfinite(value)) {
    throw std::runtime_error("phase-energy leaf returned non-finite value");
  }
  return value;
}

double bestFutureValue(const State& state, int depth, const Config& config,
                       SearchContext& context) {
  ++context.nodes;
  checkBudget(context);
  if (state.game_over) return frozen::kTerminalUtility;
  if (depth == 0) return evaluateLeaf(state, config, context);
  const std::string key = cfpi::detail::dynamicStateKey(state, depth);
  const auto cached = context.cache.find(key);
  if (cached != context.cache.end()) {
    ++context.cache_hits;
    const double value = cached->second.value;
    context.order.splice(context.order.end(), context.order,
                         cached->second.order);
    return value;
  }
  double best = -std::numeric_limits<double>::infinity();
  for (const int column : cfpi::detail::kColumnOrder) {
    if (!isLegal(state.board, column)) continue;
    best = std::max(
        best, evaluateAction(state, column, depth, config, context).value);
  }
  if (!std::isfinite(best)) best = frozen::kTerminalUtility;
  cacheValue(context, key, best);
  return best;
}

struct RootEvaluation {
  int action = -1;
  double value = -std::numeric_limits<double>::infinity();
  std::array<double, kBoardSize> values{};
  std::array<double, kBoardSize> expected_scores{};
};

RootEvaluation rootDecision(const State& state, int depth,
                            const Config& config, SearchContext& context) {
  RootEvaluation result;
  result.values.fill(-std::numeric_limits<double>::infinity());
  result.expected_scores.fill(-std::numeric_limits<double>::infinity());
  for (const int column : cfpi::detail::kColumnOrder) {
    if (!isLegal(state.board, column)) continue;
    const ActionValue value =
        evaluateAction(state, column, depth, config, context);
    result.values[column] = value.value;
    result.expected_scores[column] = value.expected_score;
    if (value.value > result.value) {
      result.value = value.value;
      result.action = column;
    }
  }
  return result;
}

struct SearchDecision {
  int action = -1;
  int completed_depth = 0;
  bool complete = false;
  std::uint64_t nodes = 0;
  std::uint64_t work = 0;
  std::uint64_t cache_hits = 0;
  std::size_t cache_entries = 0;
  std::array<double, kBoardSize> root_values{};
  std::array<double, kBoardSize> root_expected_scores{};
};

SearchDecision chooseAction(const State& source, const Config& config) {
  if (source.game_over) return {};
  bool mirrored = false;
  const State canonical =
      cfpi::detail::canonicalState(publicState(source), mirrored);
  SearchContext context;
  RootEvaluation completed;
  int completed_depth = 0;
  for (int depth = 1; depth <= kDepth; ++depth) {
    try {
      completed = rootDecision(canonical, depth, config, context);
      if (completed.action < 0) break;
      completed_depth = depth;
    } catch (const WorkLimitReached&) {
      break;
    }
  }
  int action = completed.action;
  if (action < 0) action = centerFirstMove(canonical.board);
  SearchDecision result;
  result.action = mirrored ? kBoardSize - 1 - action : action;
  result.completed_depth = completed_depth;
  result.complete = completed_depth == kDepth;
  result.nodes = context.nodes;
  result.work = context.work;
  result.cache_hits = context.cache_hits;
  result.cache_entries = context.cache.size();
  result.root_values.fill(-std::numeric_limits<double>::infinity());
  result.root_expected_scores.fill(-std::numeric_limits<double>::infinity());
  if (completed_depth > 0) {
    for (int column = 0; column < kBoardSize; ++column) {
      const int source_column =
          mirrored ? kBoardSize - 1 - column : column;
      result.root_values[source_column] = completed.values[column];
      result.root_expected_scores[source_column] =
          completed.expected_scores[column];
    }
  }
  return result;
}

struct GameResult {
  std::uint32_t seed = 0;
  int config = 0;
  std::int64_t score = 0;
  int moves = 0;
  bool censored = false;
  std::uint64_t numbered_cleared = 0;
  std::uint64_t covers_revealed = 0;
  int maximum_chain = 0;
  int cleared_boards = 0;
  std::uint64_t work = 0;
  std::uint64_t nodes = 0;
  std::uint64_t cache_hits = 0;
  std::size_t peak_cache_entries = 0;
  double decision_seconds = 0.0;
};

GameResult runGame(std::uint32_t seed, int config_index) {
  const Config& config = kMenu.at(static_cast<std::size_t>(config_index));
  State state = initialHeadlessState(seed);
  GameResult result;
  result.seed = seed;
  result.config = config_index;
  while (!state.game_over && state.moves_played < kMaximumMoves) {
    const auto started = Clock::now();
    const SearchDecision decision = chooseAction(state, config);
    result.decision_seconds +=
        std::chrono::duration<double>(Clock::now() - started).count();
    if (!decision.complete || decision.completed_depth != kDepth) {
      throw std::runtime_error("phase-energy D4 did not complete");
    }
    if (decision.work > kMaximumWork ||
        decision.cache_entries > kMaximumCacheEntries) {
      throw std::runtime_error("phase-energy D4 exceeded resource bound");
    }
    result.work += decision.work;
    result.nodes += decision.nodes;
    result.cache_hits += decision.cache_hits;
    result.peak_cache_entries =
        std::max(result.peak_cache_entries, decision.cache_entries);
    if (!isLegal(state.board, decision.action)) {
      throw std::runtime_error("phase-energy D4 returned illegal action");
    }
    MoveResult move;
    if (!playHeadlessMove(state, seed, decision.action, move)) {
      throw std::runtime_error("phase-energy headless transition failed");
    }
    for (const Wave& wave : move.waves) {
      result.numbered_cleared += static_cast<std::uint64_t>(wave.cleared);
      result.covers_revealed += static_cast<std::uint64_t>(wave.revealed);
      result.maximum_chain = std::max(result.maximum_chain, wave.depth);
    }
    if (move.cleared_board) ++result.cleared_boards;
  }
  result.score = state.score;
  result.moves = state.moves_played;
  result.censored = !state.game_over;
  return result;
}

using MenuGames = std::array<std::vector<GameResult>, kMenu.size()>;

MenuGames runMenu(std::uint32_t start, int games,
                  const std::vector<int>& configs, int threads,
                  std::string_view label) {
  MenuGames result;
  struct Job {
    int config;
    int game;
  };
  std::vector<Job> jobs;
  for (const int config : configs) {
    result[static_cast<std::size_t>(config)].resize(
        static_cast<std::size_t>(games));
    for (int game = 0; game < games; ++game) jobs.push_back({config, game});
  }
  std::atomic<std::size_t> next{0};
  const int worker_count =
      std::max(1, std::min(threads, static_cast<int>(jobs.size())));
  std::vector<std::future<void>> workers;
  for (int worker = 0; worker < worker_count; ++worker) {
    workers.push_back(std::async(std::launch::async, [&, worker]() {
      static_cast<void>(worker);
      for (;;) {
        const std::size_t index = next.fetch_add(1);
        if (index >= jobs.size()) break;
        const Job job = jobs[index];
        const std::uint32_t seed =
            start + static_cast<std::uint32_t>(job.game);
        GameResult game = runGame(seed, job.config);
        result[static_cast<std::size_t>(job.config)]
              [static_cast<std::size_t>(job.game)] = game;
        const std::lock_guard<std::mutex> lock(progress_mutex);
        std::cerr << "phase-energy " << label << ' ' << kMenu[job.config].name
                  << " seed 0x" << std::hex << seed << std::dec << ' '
                  << game.score << " points/" << game.moves << " moves, "
                  << game.numbered_cleared << " clears\n";
      }
    }));
  }
  for (auto& worker : workers) worker.get();
  return result;
}

void appendMenu(MenuGames& destination, MenuGames&& source,
                const std::vector<int>& configs) {
  for (const int config : configs) {
    auto& target = destination[static_cast<std::size_t>(config)];
    auto& values = source[static_cast<std::size_t>(config)];
    target.insert(target.end(), std::make_move_iterator(values.begin()),
                  std::make_move_iterator(values.end()));
  }
}

struct Summary {
  int games = 0;
  int censored = 0;
  double mean_score = 0.0;
  double median_score = 0.0;
  double standard_error = 0.0;
  double mean_moves = 0.0;
  double clears_per_move = 0.0;
  double reveals_per_move = 0.0;
  double mean_maximum_chain = 0.0;
  double mean_decision_ms = 0.0;
  double mean_work_per_move = 0.0;
  std::size_t peak_cache_entries = 0;
};

Summary summarize(const std::vector<GameResult>& games,
                  std::optional<int> omitted = std::nullopt) {
  Summary result;
  std::vector<double> scores;
  double score_square_sum = 0.0;
  std::uint64_t moves = 0;
  std::uint64_t clears = 0;
  std::uint64_t reveals = 0;
  std::uint64_t work = 0;
  double seconds = 0.0;
  for (std::size_t index = 0; index < games.size(); ++index) {
    if (omitted.has_value() && static_cast<int>(index) == *omitted) continue;
    const GameResult& game = games[index];
    ++result.games;
    result.censored += game.censored ? 1 : 0;
    result.mean_score += static_cast<double>(game.score);
    score_square_sum += static_cast<double>(game.score) * game.score;
    scores.push_back(static_cast<double>(game.score));
    moves += game.moves;
    clears += game.numbered_cleared;
    reveals += game.covers_revealed;
    work += game.work;
    seconds += game.decision_seconds;
    result.mean_maximum_chain += game.maximum_chain;
    result.peak_cache_entries =
        std::max(result.peak_cache_entries, game.peak_cache_entries);
  }
  if (result.games == 0) return result;
  result.mean_score /= result.games;
  result.mean_maximum_chain /= result.games;
  std::sort(scores.begin(), scores.end());
  if (scores.size() % 2 == 1) {
    result.median_score = scores[scores.size() / 2];
  } else {
    result.median_score =
        0.5 * (scores[scores.size() / 2 - 1] + scores[scores.size() / 2]);
  }
  if (result.games > 1) {
    const double variance =
        (score_square_sum - result.games * result.mean_score * result.mean_score) /
        static_cast<double>(result.games - 1);
    result.standard_error =
        std::sqrt(std::max(0.0, variance) / result.games);
  }
  result.mean_moves = static_cast<double>(moves) / result.games;
  if (moves > 0) {
    result.clears_per_move = static_cast<double>(clears) / moves;
    result.reveals_per_move = static_cast<double>(reveals) / moves;
    result.mean_decision_ms = 1'000.0 * seconds / moves;
    result.mean_work_per_move = static_cast<double>(work) / moves;
  }
  return result;
}

double projectedStageSeconds(const MenuGames& source,
                             const std::vector<int>& configs,
                             int source_games, int target_games,
                             int threads) {
  double decision_seconds = 0.0;
  for (const int config : configs) {
    for (const GameResult& game : source[static_cast<std::size_t>(config)]) {
      decision_seconds += game.decision_seconds;
    }
  }
  if (source_games <= 0) return kWallLimitSeconds;
  const double scaled_cpu = decision_seconds * target_games / source_games;
  return kWallProjectionSafetyFactor * scaled_cpu / std::max(1, threads);
}

bool stageFitsWallBudget(const Clock::time_point& experiment_started,
                         double projected_seconds) {
  const double elapsed = std::chrono::duration<double>(
                             Clock::now() - experiment_started)
                             .count();
  return elapsed + projected_seconds <= kWallLimitSeconds;
}

struct LeaveOneOut {
  std::array<int, kMenu.size()> winner_counts{};
  int triple_wins = 0;
  bool stable = false;
};

int selectCandidate(const MenuGames& games,
                    std::optional<int> omitted = std::nullopt) {
  int selected = 1;
  Summary best = summarize(games[1], omitted);
  for (std::size_t config = 2; config < kMenu.size(); ++config) {
    const Summary candidate = summarize(games[config], omitted);
    if (candidate.mean_score > best.mean_score ||
        (candidate.mean_score == best.mean_score &&
         candidate.mean_moves > best.mean_moves)) {
      selected = static_cast<int>(config);
      best = candidate;
    }
  }
  return selected;
}

LeaveOneOut leaveOneOut(const MenuGames& games, int champion) {
  LeaveOneOut result;
  for (int omitted = 0; omitted < kFittingGames; ++omitted) {
    const int winner = selectCandidate(games, omitted);
    ++result.winner_counts[static_cast<std::size_t>(winner)];
    const Summary baseline = summarize(games[0], omitted);
    const Summary candidate = summarize(games[champion], omitted);
    if (candidate.mean_score > baseline.mean_score &&
        candidate.mean_moves > baseline.mean_moves &&
        candidate.clears_per_move > baseline.clears_per_move) {
      ++result.triple_wins;
    }
  }
  result.stable =
      result.triple_wins >= kMinimumLooStableFolds &&
      result.winner_counts[static_cast<std::size_t>(champion)] >=
          kMinimumLooStableFolds;
  return result;
}

std::uint64_t peakRssBytes() { return stock::peakRssBytes(); }

void expect(bool condition, std::string_view message) {
  if (!condition) throw std::runtime_error(std::string(message));
}

void runSelfTests() {
  const State fixture = frozen::fixtureState(frozen::kTypeScriptFixtures[1]);
  const State public_fixture = publicState(fixture);
  const stock::SearchDecision stock_decision =
      stock::chooseDepth4Action(public_fixture);
  const SearchDecision zero = chooseAction(public_fixture, kMenu[0]);
  expect(zero.action == stock_decision.action &&
             zero.completed_depth == stock_decision.completed_depth &&
             zero.work == stock_decision.work &&
             zero.nodes == stock_decision.nodes &&
             zero.cache_hits == stock_decision.cache_hits &&
             zero.cache_entries == stock_decision.cache_entries &&
             zero.root_values == stock_decision.root_values &&
             zero.root_expected_scores == stock_decision.root_expected_scores,
         "zero coefficients did not exactly reproduce stock D4");

  const SearchDecision first = chooseAction(public_fixture, kMenu[3]);
  const SearchDecision repeat = chooseAction(public_fixture, kMenu[3]);
  expect(first.action == repeat.action && first.root_values == repeat.root_values &&
             first.work == repeat.work && first.nodes == repeat.nodes,
         "candidate search was not deterministic");
  State reflected = public_fixture;
  reflected.board = cfpi::detail::mirrorBoard(public_fixture.board);
  const SearchDecision mirrored = chooseAction(reflected, kMenu[3]);
  expect(mirrored.action == kBoardSize - 1 - first.action &&
             mirrored.work == first.work,
         "candidate search was not reflection safe");
  for (int column = 0; column < kBoardSize; ++column) {
    expect(first.root_values[column] ==
               mirrored.root_values[kBoardSize - 1 - column],
           "candidate root values were not reflection safe");
  }
  State metadata = public_fixture;
  metadata.score = 8'000'000;
  metadata.level = 73;
  metadata.moves_played = 412;
  const SearchDecision metadata_decision = chooseAction(metadata, kMenu[3]);
  expect(metadata_decision.action == first.action &&
             metadata_decision.root_values == first.root_values,
         "candidate search used non-public metadata");

  State phase_state = public_fixture;
  phase_state.moves_remaining = 5;
  const double early = phaseEnergyAdjustment(phase_state, kMenu[2]);
  phase_state.moves_remaining = 1;
  const double late = phaseEnergyAdjustment(phase_state, kMenu[2]);
  expect(early > 0.0 && late < 0.0,
         "phase schedule did not change stored energy in opposite directions");
  MoveResult move;
  move.score_delta = 14;
  move.waves.push_back({1, 2, 0, 14});
  expect(transitionValue(move, kMenu[1]) == 1'214.0 &&
             transitionValue(move, kMenu[2]) == 14.0,
         "isolated clear reward ablation failed");
  expect(first.complete && first.work <= kMaximumWork &&
             first.cache_entries <= kMaximumCacheEntries,
         "candidate resource/completion proof failed");
  expect(peakRssBytes() <= kMaximumSelfTestRssBytes,
         "candidate self-test exceeded RSS bound");
}

void writeSummary(std::ostream& output, const Summary& summary) {
  output << "{\"games\":" << summary.games
         << ",\"censored\":" << summary.censored
         << ",\"meanScore\":" << summary.mean_score
         << ",\"medianScore\":" << summary.median_score
         << ",\"standardError\":" << summary.standard_error
         << ",\"meanMoves\":" << summary.mean_moves
         << ",\"numberedClearsPerMove\":" << summary.clears_per_move
         << ",\"coversRevealedPerMove\":" << summary.reveals_per_move
         << ",\"meanMaximumChain\":" << summary.mean_maximum_chain
         << ",\"meanDecisionMs\":" << summary.mean_decision_ms
         << ",\"meanWorkPerMove\":" << summary.mean_work_per_move
         << ",\"peakCacheEntries\":" << summary.peak_cache_entries << '}';
}

void writeGames(std::ostream& output, const std::vector<GameResult>& games) {
  output << '[';
  for (std::size_t index = 0; index < games.size(); ++index) {
    if (index > 0) output << ',';
    const GameResult& game = games[index];
    output << "{\"seed\":" << game.seed << ",\"score\":" << game.score
           << ",\"moves\":" << game.moves
           << ",\"censored\":" << (game.censored ? "true" : "false")
           << ",\"numberedCleared\":" << game.numbered_cleared
           << ",\"coversRevealed\":" << game.covers_revealed
           << ",\"maximumChain\":" << game.maximum_chain
           << ",\"clearedBoards\":" << game.cleared_boards
           << ",\"work\":" << game.work << ",\"nodes\":" << game.nodes
           << ",\"cacheHits\":" << game.cache_hits
           << ",\"peakCacheEntries\":" << game.peak_cache_entries
           << ",\"decisionSeconds\":" << game.decision_seconds << '}';
  }
  output << ']';
}

void writeCohort(std::ostream& output, const MenuGames& games,
                 const std::vector<int>& configs) {
  output << '[';
  for (std::size_t index = 0; index < configs.size(); ++index) {
    if (index > 0) output << ',';
    const int config = configs[index];
    output << "{\"configIndex\":" << config << ",\"summary\":";
    writeSummary(output, summarize(games[static_cast<std::size_t>(config)]));
    output << ",\"games\":";
    writeGames(output, games[static_cast<std::size_t>(config)]);
    output << '}';
  }
  output << ']';
}

struct Options {
  std::string output = "/tmp/drop7-fair-phase-energy-release.json";
  int threads = kDefaultThreads;
  bool self_test_only = false;
};

Options parseOptions(int argc, char** argv) {
  Options result;
  for (int index = 1; index < argc; ++index) {
    const std::string argument = argv[index];
    if (argument == "--self-test-only") {
      result.self_test_only = true;
      continue;
    }
    if (index + 1 >= argc) throw std::invalid_argument("missing option value");
    if (argument == "--output") {
      result.output = argv[++index];
    } else if (argument == "--threads") {
      result.threads = std::stoi(argv[++index]);
      if (result.threads < 1 || result.threads > 32) {
        throw std::invalid_argument("threads must be in [1,32]");
      }
    } else {
      throw std::invalid_argument("unknown option " + argument);
    }
  }
  return result;
}

int run(const Options& options) {
  runSelfTests();
  std::cerr << "phase-energy self-tests passed (peak RSS " << peakRssBytes()
            << " bytes)\n";
  if (options.self_test_only) return 0;
  const auto started = Clock::now();
  const std::vector<int> all_configs{0, 1, 2, 3, 4};

  MenuGames fitting =
      runMenu(kFittingStart, 1, all_configs, options.threads, "fitting-first");
  const GameResult& first_baseline = fitting[0][0];
  bool early_diagnostic_stop = true;
  for (std::size_t config = 1; config < kMenu.size(); ++config) {
    const GameResult& candidate = fitting[config][0];
    const bool material_clear_loss =
        static_cast<double>(candidate.numbered_cleared) <
        static_cast<double>(first_baseline.numbered_cleared) *
            kMaterialFirstPairClearRatio;
    if (!(candidate.score < first_baseline.score && material_clear_loss)) {
      early_diagnostic_stop = false;
    }
  }
  if (!early_diagnostic_stop) {
    MenuGames remainder = runMenu(kFittingStart + 1, kFittingGames - 1,
                                  all_configs, options.threads,
                                  "fitting-remainder");
    appendMenu(fitting, std::move(remainder), all_configs);
  }

  int champion = -1;
  LeaveOneOut loo;
  bool fitting_gate = false;
  if (!early_diagnostic_stop) {
    champion = selectCandidate(fitting);
    loo = leaveOneOut(fitting, champion);
    const Summary baseline = summarize(fitting[0]);
    const Summary candidate = summarize(fitting[champion]);
    fitting_gate = baseline.censored == 0 && candidate.censored == 0 &&
                   candidate.mean_score > baseline.mean_score &&
                   candidate.mean_moves > baseline.mean_moves &&
                   candidate.clears_per_move > baseline.clears_per_move &&
                   loo.stable;
  }

  const std::vector<int> pair_configs = champion > 0
                                            ? std::vector<int>{0, champion}
                                            : std::vector<int>{0};
  MenuGames heldout;
  MenuGames screen;
  MenuGames confirmation;
  bool heldout_gate = false;
  bool screen_gate = false;
  bool confirmation_gate = false;
  std::string resource_stop_stage;
  const double heldout_projection =
      champion > 0
          ? projectedStageSeconds(fitting, pair_configs, kFittingGames,
                                  kHeldoutGames, options.threads)
          : kWallLimitSeconds;
  if (fitting_gate &&
      !stageFitsWallBudget(started, heldout_projection)) {
    resource_stop_stage = "heldout";
  }
  if (fitting_gate && resource_stop_stage.empty()) {
    heldout = runMenu(kHeldoutStart, kHeldoutGames, pair_configs,
                      options.threads, "heldout");
    const Summary baseline = summarize(heldout[0]);
    const Summary candidate = summarize(heldout[champion]);
    heldout_gate = baseline.censored == 0 && candidate.censored == 0 &&
                   candidate.mean_score > baseline.mean_score &&
                   candidate.mean_moves > baseline.mean_moves &&
                   candidate.clears_per_move >= baseline.clears_per_move &&
                   candidate.reveals_per_move >= baseline.reveals_per_move;
  }
  const double screen_projection =
      heldout_gate
          ? projectedStageSeconds(heldout, pair_configs, kHeldoutGames,
                                  kScreenGames, options.threads)
          : kWallLimitSeconds;
  if (fitting_gate && heldout_gate &&
      !stageFitsWallBudget(started, screen_projection)) {
    resource_stop_stage = "screen";
  }
  if (fitting_gate && heldout_gate && resource_stop_stage.empty()) {
    screen = runMenu(kScreenStart, kScreenGames, pair_configs,
                     options.threads, "fresh-screen");
    const Summary baseline = summarize(screen[0]);
    const Summary candidate = summarize(screen[champion]);
    screen_gate = baseline.censored == 0 && candidate.censored == 0 &&
                  candidate.mean_score > baseline.mean_score &&
                  candidate.mean_moves > baseline.mean_moves;
  }
  const double confirmation_projection =
      screen_gate
          ? projectedStageSeconds(screen, pair_configs, kScreenGames,
                                  kConfirmationGames, options.threads)
          : kWallLimitSeconds;
  if (screen_gate &&
      !stageFitsWallBudget(started, confirmation_projection)) {
    resource_stop_stage = "confirmation";
  }
  if (screen_gate && resource_stop_stage.empty()) {
    confirmation = runMenu(kConfirmationStart, kConfirmationGames,
                           pair_configs, options.threads,
                           "fresh-confirmation");
    const Summary baseline = summarize(confirmation[0]);
    const Summary candidate = summarize(confirmation[champion]);
    confirmation_gate =
        baseline.censored == 0 && candidate.censored == 0 &&
        candidate.mean_score > baseline.mean_score &&
        candidate.mean_moves > baseline.mean_moves &&
        candidate.clears_per_move >= baseline.clears_per_move &&
        candidate.reveals_per_move >= baseline.reveals_per_move;
  }

  const double wall_seconds =
      std::chrono::duration<double>(Clock::now() - started).count();
  std::ofstream output(options.output);
  if (!output) throw std::runtime_error("could not open phase-energy artifact");
  output << std::setprecision(12)
         << "{\n  \"experiment\":\"fair-d4-phase-energy-release\",\n"
         << "  \"preregistered\":true,\n"
         << "  \"publicStateOnly\":true,\n"
         << "  \"search\":{\"depth\":" << kDepth
         << ",\"chanceSamples\":" << kChanceSamples
         << ",\"fullWidth\":true,\"maximumMoves\":" << kMaximumMoves
         << ",\"maximumWork\":" << kMaximumWork
         << ",\"worstCaseWork\":" << kWorstCaseWork
         << ",\"maximumCacheEntries\":" << kMaximumCacheEntries
         << ",\"worstCaseCacheEntries\":" << kWorstCaseCacheEntries
         << ",\"wallLimitSeconds\":" << kWallLimitSeconds
         << ",\"wallProjectionSafetyFactor\":"
         << kWallProjectionSafetyFactor
         << "},\n  \"seedDiscipline\":{\"fittingStart\":"
         << kFittingStart << ",\"fittingGames\":" << kFittingGames
         << ",\"heldoutStart\":" << kHeldoutStart
         << ",\"heldoutGames\":" << kHeldoutGames
         << ",\"screenStart\":" << kScreenStart
         << ",\"screenGames\":" << kScreenGames
         << ",\"confirmationStart\":" << kConfirmationStart
         << ",\"confirmationGames\":" << kConfirmationGames << "},\n"
         << "  \"stockEnergyWeights\":{\"direct\":"
         << frozen::kDirectPotentialWeight << ",\"latent\":"
         << frozen::kLatentChainPotentialWeight
         << "},\n  \"phaseEnergyDeltaByMovesRemaining\":[";
  for (std::size_t index = 0; index < kPhaseEnergyDelta.size(); ++index) {
    if (index > 0) output << ',';
    output << kPhaseEnergyDelta[index];
  }
  output << "],\n  \"menu\":[";
  for (std::size_t index = 0; index < kMenu.size(); ++index) {
    if (index > 0) output << ',';
    output << "{\"index\":" << index << ",\"name\":\""
           << kMenu[index].name << "\",\"clearReward\":"
           << kMenu[index].clear_reward << ",\"phaseStrength\":"
           << kMenu[index].phase_strength << '}';
  }
  output << "],\n  \"earlyDiagnosticStop\":"
         << (early_diagnostic_stop ? "true" : "false")
         << ",\n  \"fitting\":";
  writeCohort(output, fitting, all_configs);
  output << ",\n  \"selection\":{\"championIndex\":" << champion
         << ",\"minimumStableFolds\":" << kMinimumLooStableFolds
         << ",\"tripleWinFolds\":" << loo.triple_wins
         << ",\"winnerCounts\":[";
  for (std::size_t index = 0; index < loo.winner_counts.size(); ++index) {
    if (index > 0) output << ',';
    output << loo.winner_counts[index];
  }
  output << "],\"leaveOneOutStable\":" << (loo.stable ? "true" : "false")
         << ",\"fittingGatePassed\":" << (fitting_gate ? "true" : "false")
         << "},\n  \"heldoutRan\":"
         << (fitting_gate && resource_stop_stage != "heldout" ? "true"
                                                               : "false")
         << ",\n  \"heldout\":";
  if (!fitting_gate || resource_stop_stage == "heldout") output << "null";
  else writeCohort(output, heldout, pair_configs);
  output << ",\n  \"heldoutGatePassed\":"
         << (heldout_gate ? "true" : "false")
         << ",\n  \"resourceStopStage\":";
  if (resource_stop_stage.empty()) output << "null";
  else output << '\"' << resource_stop_stage << '\"';
  output << ",\n  \"stageProjections\":{\"heldout\":"
         << heldout_projection << ",\"screen\":" << screen_projection
         << ",\"confirmation\":" << confirmation_projection
         << "},\n  \"screenRan\":"
         << (fitting_gate && heldout_gate && resource_stop_stage != "screen"
                 ? "true"
                 : "false")
         << ",\n  \"screen\":";
  if (!(fitting_gate && heldout_gate) || resource_stop_stage == "screen") {
    output << "null";
  }
  else writeCohort(output, screen, pair_configs);
  output << ",\n  \"screenGatePassed\":"
         << (screen_gate ? "true" : "false")
         << ",\n  \"confirmationRan\":"
         << (screen_gate && resource_stop_stage != "confirmation" ? "true"
                                                                    : "false")
         << ",\n  \"confirmation\":";
  if (!screen_gate || resource_stop_stage == "confirmation") output << "null";
  else writeCohort(output, confirmation, pair_configs);
  output << ",\n  \"confirmationGatePassed\":"
         << (confirmation_gate ? "true" : "false")
         << ",\n  \"qualified\":"
         << (screen_gate && confirmation_gate ? "true" : "false")
         << ",\n  \"wallSeconds\":" << wall_seconds
         << ",\n  \"peakRssBytes\":" << peakRssBytes() << "\n}\n";
  output.close();

  std::cout << "early-stop=" << (early_diagnostic_stop ? "yes" : "no")
            << " champion=" << champion
            << " fitting=" << (fitting_gate ? "pass" : "fail")
            << " heldout=" << (heldout_gate ? "pass" : "fail")
            << " screen=" << (screen_gate ? "pass" : "not-pass")
            << " confirmation="
            << (confirmation_gate ? "pass" : "not-pass")
            << " artifact=" << options.output << '\n';
  return screen_gate && confirmation_gate ? 0 : 2;
}

}  // namespace drop7::fair_phase_energy_release

#ifndef DROP7_FAIR_PHASE_ENERGY_RELEASE_LIBRARY
int main(int argc, char** argv) {
  try {
    const auto options =
        drop7::fair_phase_energy_release::parseOptions(argc, argv);
    return drop7::fair_phase_energy_release::run(options);
  } catch (const std::exception& error) {
    std::cerr << "drop7_fair_phase_energy_release: " << error.what() << '\n';
    return 1;
  }
}
#endif