Drop7 Research
approaches/fair-expectimax/selective-depth/fair-selective-depth.cppC++1,304 lines · 50.8 KB
#define DROP7_FAIR_ONLY_DEPTH4_NO_MAIN
#include "../reference/fair-only-depth4.cpp"
#undef DROP7_FAIR_ONLY_DEPTH4_NO_MAIN

#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <future>
#include <iomanip>
#include <iostream>
#include <limits>
#include <list>
#include <mutex>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/resource.h>
#include <unordered_map>
#include <utility>
#include <vector>

// A bounded, public-state-only selective extension of the reference fair-D4
// search.  Every root action remains full width.  At internal max nodes, legal
// actions are ordered by an exact one-ply fair evaluation using the same five
// stratified chance outcomes, then only the configured leading actions receive
// the deeper search.  The fit menu and every data gate are fixed below.
namespace drop7::fair_selective_depth {

namespace d4 = drop7::fair_only_depth4;
namespace frozen = drop7::fair_only_horizon;

constexpr int kChanceSamples = frozen::kChanceSamples;
constexpr std::uint64_t kMaximumSelectiveWork = 3'200'000;
constexpr std::size_t kMaximumCacheEntries = 45'000;
constexpr std::uint64_t kResidentTargetBytes = 64u * 1024u * 1024u;
constexpr std::uint32_t kTrainingSeedStart = 0x3dd0'0000u;
constexpr std::uint32_t kHeldoutSeedStart = 0x3dd1'0000u;
constexpr std::uint32_t kScreenSeedStart = 0x3ea7'0000u;
constexpr std::uint32_t kConfirmationSeedStart = 0x3ea8'0000u;
constexpr int kTrainingGames = 4;
constexpr int kHeldoutGames = 8;
constexpr int kScreenGames = 8;
constexpr int kConfirmationGames = 16;
constexpr int kTrainingMaximumMoves = 500;
constexpr int kEvaluationMaximumMoves = 1'000;
constexpr int kParallelism = 2;
constexpr double kLowerTailFloorRatio = 0.75;

enum class PolicyKind { kExactD4, kUniformSelective, kPhaseAligned };

struct PolicySpec {
  const char* name = "";
  PolicyKind kind = PolicyKind::kUniformSelective;
  int depth = 0;
  int internal_width = 0;
};

constexpr PolicySpec kBaseline{"fair-d4", PolicyKind::kExactD4, 4, 7};
constexpr std::array<PolicySpec, 3> kUniformMenu{{
    {"selective-d5-w2", PolicyKind::kUniformSelective, 5, 2},
    {"selective-d5-w3", PolicyKind::kUniformSelective, 5, 3},
    {"selective-d6-w2", PolicyKind::kUniformSelective, 6, 2},
}};
// This contingency is fitted on the same training seeds only when every
// uniform candidate is weak.  It is fixed before the disjoint heldout gate.
constexpr PolicySpec kPhaseAligned{"phase-d3-d4-d5w2",
                                    PolicyKind::kPhaseAligned, 5, 2};

constexpr std::uint64_t worstSelectiveNodeWork(int depth, int width) {
  constexpr std::uint64_t full_shallow =
      kBoardSize * kChanceSamples * 2u;  // transition plus fair leaf
  if (depth == 0) return 1;
  if (depth == 1) return full_shallow;
  return full_shallow + static_cast<std::uint64_t>(width) * kChanceSamples *
                            (1u + worstSelectiveNodeWork(depth - 1, width));
}

constexpr std::uint64_t worstSelectiveRootWork(int depth, int width) {
  return kBoardSize * kChanceSamples *
         (1u + worstSelectiveNodeWork(depth - 1, width));
}

constexpr std::uint64_t worstSelectiveCacheEntries(int depth, int width) {
  std::uint64_t result = 0;
  std::uint64_t states = kBoardSize * kChanceSamples;
  for (int remaining = depth - 1; remaining >= 1; --remaining) {
    result += states;
    if (remaining > 1) {
      states *= static_cast<std::uint64_t>(width) * kChanceSamples;
    }
  }
  return result;
}

constexpr std::uint64_t kWorstD5W2Work = worstSelectiveRootWork(5, 2);
constexpr std::uint64_t kWorstD5W2Cache =
    worstSelectiveCacheEntries(5, 2);
static_assert(kWorstD5W2Work == 2'760'835);
static_assert(kWorstD5W2Cache == 38'885);
static_assert(kWorstD5W2Work < kMaximumSelectiveWork);
static_assert(kWorstD5W2Cache < kMaximumCacheEntries);
static_assert(kChanceSamples == 5);
static_assert(kLevelBonus == 7'000);
static_assert(kTrainingSeedStart + kTrainingGames < kHeldoutSeedStart);
static_assert(kHeldoutSeedStart + kHeldoutGames < kScreenSeedStart);
static_assert(kScreenSeedStart + kScreenGames < kConfirmationSeedStart);
static_assert((kTrainingSeedStart >> 24) != 0x7du &&
              (kTrainingSeedStart >> 24) != 0xd7u);
static_assert((kHeldoutSeedStart >> 24) != 0x7du &&
              (kHeldoutSeedStart >> 24) != 0xd7u);
static_assert((kScreenSeedStart >> 24) != 0x7du &&
              (kScreenSeedStart >> 24) != 0xd7u);
static_assert((kConfirmationSeedStart >> 24) != 0x7du &&
              (kConfirmationSeedStart >> 24) != 0xd7u);

std::mutex progress_mutex;

class WorkLimitReached : public std::exception {};

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

struct SearchContext {
  explicit SearchContext(const PolicySpec& policy,
                         std::uint64_t work_limit = kMaximumSelectiveWork)
      : spec(policy), maximum_work(work_limit) {}

  const PolicySpec& spec;
  std::uint64_t maximum_work;
  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;
  std::uint64_t ordering_work = 0;
  std::size_t peak_cache_entries = 0;
};

void checkBudget(const SearchContext& context) {
  if (context.work >= context.maximum_work) 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});
  context.peak_cache_entries =
      std::max(context.peak_cache_entries, context.cache.size());
}

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

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

ActionValue evaluateAction(const State& state, int column, int depth,
                           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);
    result.expected_score += score_delta;
    if (move.state.game_over) {
      result.value += score_delta + frozen::kTerminalUtility;
      continue;
    }
    move.state.score = 0;
    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 += score_delta + bestFutureValue(next, depth - 1, context);
  }
  result.value /= kChanceSamples;
  result.expected_score /= kChanceSamples;
  return result;
}

double evaluateLeaf(const State& state, SearchContext& context) {
  checkBudget(context);
  ++context.work;
  const double value = frozen::fairLeaf(state);
  if (!std::isfinite(value)) {
    throw std::runtime_error("selective fair leaf returned non-finite value");
  }
  return value;
}

std::vector<int> rankedInternalActions(const State& state, int depth,
                                       SearchContext& context) {
  std::vector<int> actions;
  for (const int column : cfpi::detail::kColumnOrder) {
    if (isLegal(state.board, column)) actions.push_back(column);
  }
  if (depth <= 1 ||
      actions.size() <= static_cast<std::size_t>(context.spec.internal_width)) {
    return actions;
  }
  struct RankedAction {
    int column = -1;
    double value = 0.0;
  };
  std::vector<RankedAction> ranked;
  ranked.reserve(actions.size());
  for (const int column : actions) {
    const std::uint64_t before = context.work;
    const ActionValue shallow = evaluateAction(state, column, 1, context);
    context.ordering_work += context.work - before;
    ranked.push_back({column, shallow.value});
  }
  std::stable_sort(ranked.begin(), ranked.end(),
                   [](const RankedAction& first,
                      const RankedAction& second) {
                     return first.value > second.value;
                   });
  actions.clear();
  const int retained = std::min(context.spec.internal_width,
                                static_cast<int>(ranked.size()));
  for (int index = 0; index < retained; ++index) {
    actions.push_back(ranked[index].column);
  }
  return actions;
}

double bestFutureValue(const State& state, int depth,
                       SearchContext& context) {
  ++context.nodes;
  checkBudget(context);
  if (state.game_over) return frozen::kTerminalUtility;
  if (depth == 0) return evaluateLeaf(state, 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();
  const std::vector<int> actions =
      rankedInternalActions(state, depth, context);
  for (const int column : actions) {
    best = std::max(best, evaluateAction(state, column, depth, 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();
  int legal_actions = 0;
  int completed_actions = 0;
  std::array<double, kBoardSize> values{};
};

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

struct SearchDecision {
  int action = -1;
  int requested_depth = 0;
  int internal_width = 0;
  bool complete = false;
  bool selective_complete = false;
  bool used_fallback = false;
  bool full_root = false;
  std::uint64_t work = 0;
  std::uint64_t selective_work = 0;
  std::uint64_t fallback_work = 0;
  std::uint64_t nodes = 0;
  std::uint64_t cache_hits = 0;
  std::uint64_t ordering_work = 0;
  std::size_t peak_cache_entries = 0;
};

SearchDecision wrapDepth4(const d4::SearchDecision& decision) {
  return {decision.action,
          4,
          7,
          decision.complete && decision.completed_depth == 4,
          false,
          false,
          true,
          decision.work,
          0,
          0,
          decision.nodes,
          decision.cache_hits,
          0,
          decision.cache_entries};
}

SearchDecision wrapDepth3(const frozen::SearchDecision& decision) {
  return {decision.action,
          3,
          7,
          decision.complete && decision.completed_depth == 3,
          false,
          false,
          true,
          decision.work,
          0,
          0,
          decision.nodes,
          decision.cache_hits,
          0,
          decision.cache_entries};
}

SearchDecision chooseUniformSelective(
    const State& source, const PolicySpec& spec,
    const d4::SearchDecision* known_fallback = nullptr,
    std::uint64_t work_limit = kMaximumSelectiveWork) {
  if (source.game_over) return {};
  bool mirrored = false;
  const State canonical = cfpi::detail::canonicalState(source, mirrored);
  RootEvaluation root;
  SearchDecision result;
  result.requested_depth = spec.depth;
  result.internal_width = spec.internal_width;
  {
    SearchContext context(spec, work_limit);
    try {
      root = rootDecision(canonical, spec.depth, context);
      result.selective_complete =
          root.action >= 0 && root.completed_actions == root.legal_actions;
    } catch (const WorkLimitReached&) {
      result.selective_complete = false;
    }
    result.selective_work = context.work;
    result.nodes = context.nodes;
    result.cache_hits = context.cache_hits;
    result.ordering_work = context.ordering_work;
    result.peak_cache_entries = context.peak_cache_entries;
  }
  if (result.selective_complete) {
    result.action = mirrored ? kBoardSize - 1 - root.action : root.action;
    result.complete = true;
    result.full_root = true;
    result.work = result.selective_work;
    return result;
  }

  const d4::SearchDecision fallback =
      known_fallback == nullptr ? d4::chooseDepth4Action(source)
                                : *known_fallback;
  if (!fallback.complete || fallback.completed_depth != 4 ||
      !isLegal(source.board, fallback.action)) {
    throw std::runtime_error("selective fallback did not complete fair D4");
  }
  result.action = fallback.action;
  result.complete = true;
  result.used_fallback = true;
  result.full_root = true;
  result.fallback_work = fallback.work;
  result.work += result.selective_work + result.fallback_work;
  result.nodes += fallback.nodes;
  result.cache_hits += fallback.cache_hits;
  result.peak_cache_entries =
      std::max(result.peak_cache_entries, fallback.cache_entries);
  return result;
}

SearchDecision choosePolicyAction(
    const State& source, const PolicySpec& spec,
    const d4::SearchDecision* known_d4 = nullptr,
    std::uint64_t work_limit = kMaximumSelectiveWork) {
  if (spec.kind == PolicyKind::kExactD4) {
    const d4::SearchDecision decision =
        known_d4 == nullptr ? d4::chooseDepth4Action(source) : *known_d4;
    return wrapDepth4(decision);
  }
  if (spec.kind == PolicyKind::kUniformSelective) {
    return chooseUniformSelective(source, spec, known_d4, work_limit);
  }
  if (source.moves_remaining <= 3) {
    return wrapDepth3(frozen::chooseFairAction(source));
  }
  if (source.moves_remaining == 4) {
    const d4::SearchDecision decision =
        known_d4 == nullptr ? d4::chooseDepth4Action(source) : *known_d4;
    return wrapDepth4(decision);
  }
  return chooseUniformSelective(source, kUniformMenu[0], known_d4,
                                work_limit);
}

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

struct PhaseStats {
  std::uint64_t decisions = 0;
  std::uint64_t switches_from_d4 = 0;
  std::uint64_t fallbacks = 0;
  std::int64_t score_delta = 0;
  std::uint64_t numbered_cleared = 0;
  std::uint64_t covers_revealed = 0;
  std::uint64_t policy_work = 0;
  std::uint64_t d4_reference_work = 0;
};

struct GameResult {
  std::uint32_t seed = 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;
  std::uint64_t switches_from_d4 = 0;
  std::uint64_t fallbacks = 0;
  std::uint64_t incomplete_selective = 0;
  std::uint64_t root_width_violations = 0;
  std::uint64_t work = 0;
  std::uint64_t selective_work = 0;
  std::uint64_t fallback_work = 0;
  std::uint64_t d4_reference_work = 0;
  std::uint64_t nodes = 0;
  std::uint64_t cache_hits = 0;
  std::uint64_t ordering_work = 0;
  std::size_t peak_cache_entries = 0;
  std::array<PhaseStats, kMovesPerLevel> phase{};
  std::uint64_t peak_rss_bytes = 0;
  double elapsed_seconds = 0.0;
};

void observeMove(const MoveResult& move, GameResult& result,
                 PhaseStats& phase) {
  result.maximum_chain =
      std::max(result.maximum_chain, static_cast<int>(move.waves.size()));
  phase.score_delta += move.score_delta;
  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);
    phase.numbered_cleared += static_cast<std::uint64_t>(wave.cleared);
    phase.covers_revealed += static_cast<std::uint64_t>(wave.revealed);
  }
}

void reportGame(std::string_view phase_name, const PolicySpec& spec,
                const GameResult& result) {
  const std::lock_guard<std::mutex> lock(progress_mutex);
  std::cerr << phase_name << ' ' << spec.name << " seed 0x" << std::hex
            << result.seed << std::dec << ' ' << result.score << " ("
            << result.moves << " moves"
            << (result.censored ? ", capped" : "") << ", switches "
            << result.switches_from_d4 << ", fallbacks " << result.fallbacks
            << ", work " << result.work << ", cache "
            << result.peak_cache_entries << ")\n";
}

GameResult runPolicyGame(const PolicySpec& spec, std::uint32_t seed,
                         int maximum_moves, bool compare_to_d4,
                         std::string_view phase_name) {
  const auto started = std::chrono::steady_clock::now();
  State state = initialHeadlessState(seed);
  GameResult result;
  result.seed = seed;
  while (!state.game_over && state.moves_played < maximum_moves) {
    const int phase_index = state.moves_remaining - 1;
    if (phase_index < 0 || phase_index >= kMovesPerLevel) {
      throw std::runtime_error("invalid moves-remaining phase");
    }
    std::optional<d4::SearchDecision> reference;
    if (compare_to_d4) reference = d4::chooseDepth4Action(state);
    const SearchDecision decision = choosePolicyAction(
        state, spec, reference ? &*reference : nullptr);
    if (!decision.complete || !decision.full_root ||
        !isLegal(state.board, decision.action)) {
      throw std::runtime_error("selective policy returned incomplete action");
    }
    PhaseStats& phase = result.phase[phase_index];
    ++phase.decisions;
    phase.policy_work += decision.work;
    phase.fallbacks += decision.used_fallback;
    result.fallbacks += decision.used_fallback;
    result.incomplete_selective += !decision.selective_complete &&
                                   spec.kind == PolicyKind::kUniformSelective;
    result.root_width_violations += !decision.full_root;
    result.work += decision.work;
    result.selective_work += decision.selective_work;
    result.fallback_work += decision.fallback_work;
    result.nodes += decision.nodes;
    result.cache_hits += decision.cache_hits;
    result.ordering_work += decision.ordering_work;
    result.peak_cache_entries =
        std::max(result.peak_cache_entries, decision.peak_cache_entries);
    if (reference) {
      if (!reference->complete || reference->completed_depth != 4) {
        throw std::runtime_error("diagnostic D4 reference was incomplete");
      }
      phase.d4_reference_work += reference->work;
      result.d4_reference_work += reference->work;
      const bool switched = decision.action != reference->action;
      phase.switches_from_d4 += switched;
      result.switches_from_d4 += switched;
    }
    MoveResult move;
    if (!playHeadlessMove(state, seed, decision.action, move)) {
      throw std::runtime_error("selective policy transition failed");
    }
    observeMove(move, result, phase);
  }
  result.score = state.score;
  result.moves = state.moves_played;
  result.censored = !state.game_over;
  result.peak_rss_bytes = peakRssBytes();
  result.elapsed_seconds = std::chrono::duration<double>(
                               std::chrono::steady_clock::now() - started)
                               .count();
  reportGame(phase_name, spec, result);
  return result;
}

struct Cohort {
  PolicySpec spec{};
  int maximum_moves = 0;
  std::vector<GameResult> games;
  double wall_seconds = 0.0;
};

Cohort runCohort(const PolicySpec& spec, std::uint32_t seed_start, int games,
                 int maximum_moves, bool compare_to_d4,
                 std::string_view phase_name) {
  const auto started = std::chrono::steady_clock::now();
  Cohort result;
  result.spec = spec;
  result.maximum_moves = maximum_moves;
  result.games.resize(games);
  std::atomic<int> next_game{0};
  std::vector<std::future<void>> workers;
  for (int worker = 0; worker < std::min(kParallelism, games); ++worker) {
    workers.push_back(std::async(std::launch::async, [&] {
      for (;;) {
        const int game = next_game.fetch_add(1);
        if (game >= games) return;
        result.games[game] = runPolicyGame(
            spec, seed_start + static_cast<std::uint32_t>(game),
            maximum_moves, compare_to_d4, phase_name);
      }
    }));
  }
  for (auto& worker : workers) worker.get();
  result.wall_seconds = std::chrono::duration<double>(
                            std::chrono::steady_clock::now() - started)
                            .count();
  return result;
}

struct Summary {
  int games = 0;
  double mean_score = 0.0;
  double mean_moves = 0.0;
  int censored = 0;
  double mean_numbered_cleared = 0.0;
  double mean_covers_revealed = 0.0;
  double clears_per_move = 0.0;
  double reveals_per_move = 0.0;
  double mean_maximum_chain = 0.0;
  std::uint64_t switches_from_d4 = 0;
  double switch_rate = 0.0;
  std::uint64_t fallbacks = 0;
  double fallback_rate = 0.0;
  std::uint64_t incomplete_selective = 0;
  std::uint64_t root_width_violations = 0;
  std::uint64_t work = 0;
  double work_per_move = 0.0;
  std::uint64_t selective_work = 0;
  std::uint64_t fallback_work = 0;
  std::uint64_t d4_reference_work = 0;
  std::uint64_t nodes = 0;
  std::uint64_t cache_hits = 0;
  std::uint64_t ordering_work = 0;
  std::size_t peak_cache_entries = 0;
  std::array<PhaseStats, kMovesPerLevel> phase{};
  double aggregate_game_seconds = 0.0;
  double moves_per_game_second = 0.0;
  std::uint64_t peak_rss_bytes = 0;
};

Summary summarize(const Cohort& cohort) {
  if (cohort.games.empty()) throw std::invalid_argument("empty cohort");
  Summary result;
  result.games = static_cast<int>(cohort.games.size());
  std::uint64_t moves = 0;
  std::uint64_t cleared = 0;
  std::uint64_t revealed = 0;
  for (const GameResult& game : cohort.games) {
    result.mean_score += static_cast<double>(game.score) / result.games;
    result.mean_moves += static_cast<double>(game.moves) / result.games;
    result.censored += game.censored;
    result.mean_numbered_cleared +=
        static_cast<double>(game.numbered_cleared) / result.games;
    result.mean_covers_revealed +=
        static_cast<double>(game.covers_revealed) / result.games;
    result.mean_maximum_chain +=
        static_cast<double>(game.maximum_chain) / result.games;
    result.switches_from_d4 += game.switches_from_d4;
    result.fallbacks += game.fallbacks;
    result.incomplete_selective += game.incomplete_selective;
    result.root_width_violations += game.root_width_violations;
    result.work += game.work;
    result.selective_work += game.selective_work;
    result.fallback_work += game.fallback_work;
    result.d4_reference_work += game.d4_reference_work;
    result.nodes += game.nodes;
    result.cache_hits += game.cache_hits;
    result.ordering_work += game.ordering_work;
    result.peak_cache_entries =
        std::max(result.peak_cache_entries, game.peak_cache_entries);
    result.aggregate_game_seconds += game.elapsed_seconds;
    result.peak_rss_bytes =
        std::max(result.peak_rss_bytes, game.peak_rss_bytes);
    moves += static_cast<std::uint64_t>(game.moves);
    cleared += game.numbered_cleared;
    revealed += game.covers_revealed;
    for (int phase = 0; phase < kMovesPerLevel; ++phase) {
      PhaseStats& target = result.phase[phase];
      const PhaseStats& source = game.phase[phase];
      target.decisions += source.decisions;
      target.switches_from_d4 += source.switches_from_d4;
      target.fallbacks += source.fallbacks;
      target.score_delta += source.score_delta;
      target.numbered_cleared += source.numbered_cleared;
      target.covers_revealed += source.covers_revealed;
      target.policy_work += source.policy_work;
      target.d4_reference_work += source.d4_reference_work;
    }
  }
  const double move_count = static_cast<double>(std::max<std::uint64_t>(1, moves));
  result.clears_per_move = cleared / move_count;
  result.reveals_per_move = revealed / move_count;
  result.switch_rate = result.switches_from_d4 / move_count;
  result.fallback_rate = result.fallbacks / move_count;
  result.work_per_move = result.work / move_count;
  result.moves_per_game_second =
      move_count / std::max(1.0e-9, result.aggregate_game_seconds);
  return result;
}

struct DifferenceStats {
  double mean = 0.0;
  double lower_95 = 0.0;
  int wins = 0;
  int ties = 0;
  int losses = 0;
};

DifferenceStats differences(const std::vector<double>& values) {
  if (values.empty()) throw std::invalid_argument("empty differences");
  DifferenceStats result;
  for (const double value : values) {
    result.mean += value / values.size();
    result.wins += value > 0;
    result.ties += value == 0;
    result.losses += value < 0;
  }
  double squares = 0.0;
  for (const double value : values) {
    squares += (value - result.mean) * (value - result.mean);
  }
  const double deviation = values.size() > 1
                               ? std::sqrt(squares / (values.size() - 1))
                               : 0.0;
  result.lower_95 =
      result.mean - 1.96 * deviation / std::sqrt(values.size());
  return result;
}

double lowerTailMean(std::vector<double> values, std::size_t divisor) {
  if (values.empty()) throw std::invalid_argument("empty lower tail");
  std::sort(values.begin(), values.end());
  const std::size_t count = std::max<std::size_t>(1, values.size() / divisor);
  double result = 0.0;
  for (std::size_t index = 0; index < count; ++index) {
    result += values[index] / count;
  }
  return result;
}

struct Comparison {
  DifferenceStats score;
  DifferenceStats moves;
  double baseline_score_lower_quartile = 0.0;
  double candidate_score_lower_quartile = 0.0;
  double baseline_moves_lower_quartile = 0.0;
  double candidate_moves_lower_quartile = 0.0;
  double mean_log_utility = 0.0;
  double worst_half_log_utility = 0.0;
  double robust_training_utility = 0.0;
  bool both_means_positive = false;
  bool no_lower_tail_collapse = false;
};

Comparison compare(const Cohort& baseline, const Cohort& candidate) {
  if (baseline.games.size() != candidate.games.size() ||
      baseline.games.empty()) {
    throw std::invalid_argument("unpaired cohorts");
  }
  std::vector<double> score_deltas;
  std::vector<double> move_deltas;
  std::vector<double> baseline_scores;
  std::vector<double> candidate_scores;
  std::vector<double> baseline_moves;
  std::vector<double> candidate_moves;
  std::vector<double> utilities;
  for (std::size_t index = 0; index < baseline.games.size(); ++index) {
    const GameResult& first = baseline.games[index];
    const GameResult& second = candidate.games[index];
    if (first.seed != second.seed) throw std::invalid_argument("seed mismatch");
    score_deltas.push_back(static_cast<double>(second.score - first.score));
    move_deltas.push_back(static_cast<double>(second.moves - first.moves));
    baseline_scores.push_back(static_cast<double>(first.score));
    candidate_scores.push_back(static_cast<double>(second.score));
    baseline_moves.push_back(static_cast<double>(first.moves));
    candidate_moves.push_back(static_cast<double>(second.moves));
    const double score_ratio =
        std::log((second.score + static_cast<double>(kLevelBonus)) /
                 (first.score + static_cast<double>(kLevelBonus)));
    const double move_ratio =
        std::log((second.moves + 1.0) / (first.moves + 1.0));
    utilities.push_back(0.5 * score_ratio + 0.5 * move_ratio);
  }
  Comparison result;
  result.score = differences(score_deltas);
  result.moves = differences(move_deltas);
  result.baseline_score_lower_quartile = lowerTailMean(baseline_scores, 4);
  result.candidate_score_lower_quartile = lowerTailMean(candidate_scores, 4);
  result.baseline_moves_lower_quartile = lowerTailMean(baseline_moves, 4);
  result.candidate_moves_lower_quartile = lowerTailMean(candidate_moves, 4);
  for (const double utility : utilities) {
    result.mean_log_utility += utility / utilities.size();
  }
  result.worst_half_log_utility = lowerTailMean(utilities, 2);
  result.robust_training_utility =
      0.5 * result.mean_log_utility + 0.5 * result.worst_half_log_utility;
  result.both_means_positive = result.score.mean > 0 && result.moves.mean > 0;
  result.no_lower_tail_collapse =
      result.candidate_score_lower_quartile >=
          kLowerTailFloorRatio * result.baseline_score_lower_quartile &&
      result.candidate_moves_lower_quartile >=
          kLowerTailFloorRatio * result.baseline_moves_lower_quartile;
  return result;
}

struct MenuResult {
  Cohort cohort;
  Summary summary;
  Comparison comparison;
};

bool betterTrainingChoice(const MenuResult& first,
                          const MenuResult& second) {
  constexpr double tolerance = 1.0e-12;
  if (first.comparison.robust_training_utility >
      second.comparison.robust_training_utility + tolerance) {
    return true;
  }
  if (second.comparison.robust_training_utility >
      first.comparison.robust_training_utility + tolerance) {
    return false;
  }
  if (first.summary.work_per_move != second.summary.work_per_move) {
    return first.summary.work_per_move < second.summary.work_per_move;
  }
  return std::string_view(first.cohort.spec.name) <
         std::string_view(second.cohort.spec.name);
}

void writePhase(std::ostream& output, const PhaseStats& phase) {
  output << "{\"decisions\":" << phase.decisions
         << ",\"switchesFromD4\":" << phase.switches_from_d4
         << ",\"fallbacks\":" << phase.fallbacks
         << ",\"scoreDelta\":" << phase.score_delta
         << ",\"numberedCleared\":" << phase.numbered_cleared
         << ",\"coversRevealed\":" << phase.covers_revealed
         << ",\"policyWork\":" << phase.policy_work
         << ",\"d4ReferenceWork\":" << phase.d4_reference_work << '}';
}

void writePhases(std::ostream& output,
                 const std::array<PhaseStats, kMovesPerLevel>& phases) {
  output << '[';
  for (int index = 0; index < kMovesPerLevel; ++index) {
    if (index != 0) output << ',';
    output << "{\"movesRemaining\":" << index + 1 << ",\"stats\":";
    writePhase(output, phases[index]);
    output << '}';
  }
  output << ']';
}

void writeSpec(std::ostream& output, const PolicySpec& spec) {
  const char* kind = spec.kind == PolicyKind::kExactD4
                         ? "exact-d4"
                         : spec.kind == PolicyKind::kUniformSelective
                               ? "uniform-selective"
                               : "phase-aligned";
  output << "{\"name\":\"" << spec.name << "\",\"kind\":\"" << kind
         << "\",\"depth\":" << spec.depth
         << ",\"internalWidth\":" << spec.internal_width << '}';
}

void writeGame(std::ostream& output, const GameResult& game) {
  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
         << ",\"switchesFromD4\":" << game.switches_from_d4
         << ",\"fallbacks\":" << game.fallbacks
         << ",\"incompleteSelective\":" << game.incomplete_selective
         << ",\"rootWidthViolations\":" << game.root_width_violations
         << ",\"work\":" << game.work
         << ",\"selectiveWork\":" << game.selective_work
         << ",\"fallbackWork\":" << game.fallback_work
         << ",\"d4ReferenceWork\":" << game.d4_reference_work
         << ",\"nodes\":" << game.nodes
         << ",\"cacheHits\":" << game.cache_hits
         << ",\"orderingWork\":" << game.ordering_work
         << ",\"peakCacheEntries\":" << game.peak_cache_entries
         << ",\"peakRssBytes\":" << game.peak_rss_bytes
         << ",\"elapsedSeconds\":" << game.elapsed_seconds
         << ",\"byMovesRemaining\":";
  writePhases(output, game.phase);
  output << '}';
}

void writeSummary(std::ostream& output, const Summary& summary) {
  output << "{\"games\":" << summary.games
         << ",\"meanScore\":" << summary.mean_score
         << ",\"meanMoves\":" << summary.mean_moves
         << ",\"censored\":" << summary.censored
         << ",\"meanNumberedCleared\":" << summary.mean_numbered_cleared
         << ",\"meanCoversRevealed\":" << summary.mean_covers_revealed
         << ",\"clearsPerMove\":" << summary.clears_per_move
         << ",\"revealsPerMove\":" << summary.reveals_per_move
         << ",\"meanMaximumChain\":" << summary.mean_maximum_chain
         << ",\"switchesFromD4\":" << summary.switches_from_d4
         << ",\"switchRate\":" << summary.switch_rate
         << ",\"fallbacks\":" << summary.fallbacks
         << ",\"fallbackRate\":" << summary.fallback_rate
         << ",\"incompleteSelective\":" << summary.incomplete_selective
         << ",\"rootWidthViolations\":" << summary.root_width_violations
         << ",\"work\":" << summary.work
         << ",\"workPerMove\":" << summary.work_per_move
         << ",\"selectiveWork\":" << summary.selective_work
         << ",\"fallbackWork\":" << summary.fallback_work
         << ",\"d4ReferenceWork\":" << summary.d4_reference_work
         << ",\"nodes\":" << summary.nodes
         << ",\"cacheHits\":" << summary.cache_hits
         << ",\"orderingWork\":" << summary.ordering_work
         << ",\"peakCacheEntries\":" << summary.peak_cache_entries
         << ",\"aggregateGameSeconds\":" << summary.aggregate_game_seconds
         << ",\"movesPerGameSecond\":" << summary.moves_per_game_second
         << ",\"peakRssBytes\":" << summary.peak_rss_bytes
         << ",\"byMovesRemaining\":";
  writePhases(output, summary.phase);
  output << '}';
}

void writeDifference(std::ostream& output, const DifferenceStats& stats) {
  output << "{\"mean\":" << stats.mean << ",\"lower95\":"
         << stats.lower_95 << ",\"wins\":" << stats.wins
         << ",\"ties\":" << stats.ties << ",\"losses\":"
         << stats.losses << '}';
}

void writeComparison(std::ostream& output, const Comparison& comparison) {
  output << "{\"score\":";
  writeDifference(output, comparison.score);
  output << ",\"moves\":";
  writeDifference(output, comparison.moves);
  output << ",\"baselineScoreLowerQuartile\":"
         << comparison.baseline_score_lower_quartile
         << ",\"candidateScoreLowerQuartile\":"
         << comparison.candidate_score_lower_quartile
         << ",\"baselineMovesLowerQuartile\":"
         << comparison.baseline_moves_lower_quartile
         << ",\"candidateMovesLowerQuartile\":"
         << comparison.candidate_moves_lower_quartile
         << ",\"meanLogUtility\":" << comparison.mean_log_utility
         << ",\"worstHalfLogUtility\":"
         << comparison.worst_half_log_utility
         << ",\"robustTrainingUtility\":"
         << comparison.robust_training_utility
         << ",\"bothMeansPositive\":"
         << (comparison.both_means_positive ? "true" : "false")
         << ",\"noLowerTailCollapse\":"
         << (comparison.no_lower_tail_collapse ? "true" : "false") << '}';
}

void writeCohort(std::ostream& output, const Cohort& cohort,
                 const Summary& summary) {
  output << "{\"policy\":";
  writeSpec(output, cohort.spec);
  output << ",\"maximumMoves\":" << cohort.maximum_moves
         << ",\"wallSeconds\":" << cohort.wall_seconds
         << ",\"summary\":";
  writeSummary(output, summary);
  output << ",\"games\":[";
  for (std::size_t index = 0; index < cohort.games.size(); ++index) {
    if (index != 0) output << ',';
    writeGame(output, cohort.games[index]);
  }
  output << "]}";
}

struct EvaluationStage {
  Cohort baseline;
  Cohort candidate;
  Summary baseline_summary;
  Summary candidate_summary;
  Comparison comparison;
};

void writeStage(std::ostream& output, const EvaluationStage& stage) {
  output << "{\"baseline\":";
  writeCohort(output, stage.baseline, stage.baseline_summary);
  output << ",\"candidate\":";
  writeCohort(output, stage.candidate, stage.candidate_summary);
  output << ",\"comparison\":";
  writeComparison(output, stage.comparison);
  output << '}';
}

EvaluationStage runEvaluationStage(const PolicySpec& selected,
                                   std::uint32_t seed_start, int games,
                                   int maximum_moves,
                                   std::string_view phase_name) {
  EvaluationStage result;
  result.baseline = runCohort(kBaseline, seed_start, games, maximum_moves,
                              false, std::string(phase_name) + "-baseline");
  result.candidate = runCohort(selected, seed_start, games, maximum_moves,
                               true, std::string(phase_name) + "-candidate");
  result.baseline_summary = summarize(result.baseline);
  result.candidate_summary = summarize(result.candidate);
  result.comparison = compare(result.baseline, result.candidate);
  return result;
}

struct Options {
  std::string output = "/tmp/drop7-fair-selective-depth.json";
};

Options parseOptions(int argc, char** argv, int begin) {
  Options result;
  for (int index = begin; index < argc; index += 2) {
    if (index + 1 >= argc) throw std::invalid_argument("missing option value");
    const std::string argument = argv[index];
    if (argument == "--output") {
      result.output = argv[index + 1];
    } else {
      throw std::invalid_argument("unknown option " + argument);
    }
  }
  return result;
}

bool selfTest(std::ostream& output) {
  const bool baseline_test = d4::selfTest(output);
  State source = frozen::fixtureState(frozen::kTypeScriptFixtures[1]);
  // Leave three legal columns so the expensive invariants remain sanitizer
  // friendly while still exercising ranking and a complete selective D5 tree.
  for (const int column : {0, 1, 5, 6}) {
    for (int row = 0; row < kBoardSize; ++row) {
      source.board[indexOf(row, column)] = kSolid;
    }
  }
  source.game_over = false;
  source.moves_remaining = 5;
  const SearchDecision first = choosePolicyAction(source, kUniformMenu[0]);
  const SearchDecision repeat = choosePolicyAction(source, kUniformMenu[0]);
  State reflected = source;
  reflected.board = cfpi::detail::mirrorBoard(source.board);
  const SearchDecision mirror = choosePolicyAction(reflected, kUniformMenu[0]);
  State metadata = source;
  metadata.score = 9'999'999;
  metadata.level = 91;
  metadata.moves_played = 777;
  const SearchDecision metadata_result =
      choosePolicyAction(metadata, kUniformMenu[0]);
  const d4::SearchDecision depth4 = d4::chooseDepth4Action(source);
  const SearchDecision forced_fallback =
      choosePolicyAction(source, kUniformMenu[0], &depth4, 1);

  int legal_count = 0;
  for (int column = 0; column < kBoardSize; ++column) {
    legal_count += isLegal(source.board, column);
  }
  const bool deterministic =
      first.action == repeat.action && first.work == repeat.work &&
      first.ordering_work == repeat.ordering_work &&
      first.cache_hits == repeat.cache_hits;
  const bool reflection_safe =
      mirror.action == kBoardSize - 1 - first.action &&
      mirror.work == first.work && mirror.ordering_work == first.ordering_work;
  const bool public_only = metadata_result.action == first.action &&
                           metadata_result.work == first.work;
  const bool complete = first.complete && first.selective_complete &&
                        !first.used_fallback && first.full_root;
  const bool legal = legal_count == 3 && isLegal(source.board, first.action) &&
                     isLegal(source.board, forced_fallback.action);
  const bool bounded = first.selective_work <= kMaximumSelectiveWork &&
                       first.peak_cache_entries <= kMaximumCacheEntries;
  const bool fallback_safe =
      forced_fallback.complete && forced_fallback.used_fallback &&
      forced_fallback.action == depth4.action &&
      forced_fallback.fallback_work == depth4.work;
  const bool completion_proven =
      kWorstD5W2Work < kMaximumSelectiveWork &&
      kWorstD5W2Cache < kMaximumCacheEntries;
  const bool protocol =
      kTrainingSeedStart == 0x3dd0'0000u &&
      kHeldoutSeedStart == 0x3dd1'0000u &&
      kScreenSeedStart == 0x3ea7'0000u &&
      kConfirmationSeedStart == 0x3ea8'0000u && kTrainingGames == 4 &&
      kHeldoutGames == 8 && kScreenGames == 8 &&
      kConfirmationGames == 16 && kTrainingMaximumMoves == 500 &&
      kEvaluationMaximumMoves == 1'000;
  const bool passed = baseline_test && deterministic && reflection_safe &&
                      public_only && complete && legal && bounded &&
                      fallback_safe && completion_proven && protocol;
  output << std::setprecision(12)
         << "FAIR_SELECTIVE_DEPTH_SELF_TEST {\"passed\":"
         << (passed ? "true" : "false")
         << ",\"baselineTest\":" << (baseline_test ? "true" : "false")
         << ",\"deterministic\":" << (deterministic ? "true" : "false")
         << ",\"reflectionSafe\":"
         << (reflection_safe ? "true" : "false")
         << ",\"publicMetadataAndGameSeedBlind\":"
         << (public_only ? "true" : "false")
         << ",\"complete\":" << (complete ? "true" : "false")
         << ",\"legal\":" << (legal ? "true" : "false")
         << ",\"fullRoot\":" << (first.full_root ? "true" : "false")
         << ",\"bounded\":" << (bounded ? "true" : "false")
         << ",\"fallbackCompletedD4\":"
         << (fallback_safe ? "true" : "false")
         << ",\"fixtureAction\":" << first.action
         << ",\"fixtureWork\":" << first.work
         << ",\"fixtureOrderingWork\":" << first.ordering_work
         << ",\"fixtureCacheEntries\":" << first.peak_cache_entries
         << ",\"worstD5W2Work\":" << kWorstD5W2Work
         << ",\"worstD5W2Cache\":" << kWorstD5W2Cache
         << ",\"maximumWork\":" << kMaximumSelectiveWork
         << ",\"maximumCache\":" << kMaximumCacheEntries << "}\n";
  return passed;
}

int run(const Options& options, std::ostream& output) {
  const auto started = std::chrono::steady_clock::now();
  const Cohort training_baseline =
      runCohort(kBaseline, kTrainingSeedStart, kTrainingGames,
                kTrainingMaximumMoves, false, "training-baseline");
  const Summary training_baseline_summary = summarize(training_baseline);

  std::vector<MenuResult> menu;
  menu.reserve(kUniformMenu.size() + 1);
  for (const PolicySpec& spec : kUniformMenu) {
    MenuResult result;
    result.cohort = runCohort(spec, kTrainingSeedStart, kTrainingGames,
                              kTrainingMaximumMoves, false, "training-menu");
    result.summary = summarize(result.cohort);
    result.comparison = compare(training_baseline, result.cohort);
    {
      const std::lock_guard<std::mutex> lock(progress_mutex);
      std::cerr << "fit " << spec.name << " score-delta "
                << result.comparison.score.mean << ", move-delta "
                << result.comparison.moves.mean << ", robust-utility "
                << result.comparison.robust_training_utility
                << ", work/move " << result.summary.work_per_move << '\n';
    }
    menu.push_back(std::move(result));
  }
  auto best = std::min_element(
      menu.begin(), menu.end(),
      [](const MenuResult& first, const MenuResult& second) {
        return betterTrainingChoice(first, second);
      });
  if (best == menu.end()) throw std::runtime_error("empty fit menu");
  const bool uniform_weak =
      best->comparison.robust_training_utility <= 0.0 ||
      !best->comparison.both_means_positive;
  if (uniform_weak) {
    MenuResult contingency;
    contingency.cohort =
        runCohort(kPhaseAligned, kTrainingSeedStart, kTrainingGames,
                  kTrainingMaximumMoves, false, "training-contingency");
    contingency.summary = summarize(contingency.cohort);
    contingency.comparison = compare(training_baseline, contingency.cohort);
    {
      const std::lock_guard<std::mutex> lock(progress_mutex);
      std::cerr << "fit " << kPhaseAligned.name << " score-delta "
                << contingency.comparison.score.mean << ", move-delta "
                << contingency.comparison.moves.mean << ", robust-utility "
                << contingency.comparison.robust_training_utility
                << ", work/move " << contingency.summary.work_per_move
                << '\n';
    }
    menu.push_back(std::move(contingency));
    best = std::min_element(
        menu.begin(), menu.end(),
        [](const MenuResult& first, const MenuResult& second) {
          return betterTrainingChoice(first, second);
        });
  }
  const PolicySpec selected = best->cohort.spec;
  {
    const std::lock_guard<std::mutex> lock(progress_mutex);
    std::cerr << "frozen selection " << selected.name << " (uniform weak "
              << (uniform_weak ? "yes" : "no") << ")\n";
  }
  const bool training_memory_ok = peakRssBytes() <= kResidentTargetBytes;

  std::optional<EvaluationStage> heldout;
  std::optional<EvaluationStage> screen;
  std::optional<EvaluationStage> confirmation;
  bool heldout_passed = false;
  bool screen_passed = false;
  bool confirmation_passed = false;
  if (training_memory_ok) {
    heldout = runEvaluationStage(selected, kHeldoutSeedStart, kHeldoutGames,
                                 kTrainingMaximumMoves, "heldout");
    heldout_passed = heldout->comparison.both_means_positive &&
                     heldout->comparison.no_lower_tail_collapse &&
                     heldout->candidate_summary.root_width_violations == 0 &&
                     peakRssBytes() <= kResidentTargetBytes;
  }
  if (heldout_passed) {
    screen = runEvaluationStage(selected, kScreenSeedStart, kScreenGames,
                                kEvaluationMaximumMoves, "screen");
    screen_passed = screen->comparison.both_means_positive &&
                    peakRssBytes() <= kResidentTargetBytes;
  }
  if (screen_passed) {
    confirmation = runEvaluationStage(
        selected, kConfirmationSeedStart, kConfirmationGames,
        kEvaluationMaximumMoves, "confirmation");
    confirmation_passed =
        confirmation->comparison.both_means_positive &&
        confirmation->comparison.no_lower_tail_collapse &&
        peakRssBytes() <= kResidentTargetBytes;
  }
  const double total_wall = std::chrono::duration<double>(
                                std::chrono::steady_clock::now() - started)
                                .count();

  std::ofstream artifact(options.output);
  if (!artifact) throw std::runtime_error("could not open selective artifact");
  artifact << std::setprecision(12)
           << "{\n  \"experiment\":\"fair-public-selective-depth\",\n"
           << "  \"preregistered\":true,\n"
           << "  \"publicStateOnly\":true,\n"
           << "  \"gameSeedVisibleToSearch\":false,\n"
           << "  \"search\":{\"chanceSamples\":" << kChanceSamples
           << ",\"ordering\":\"exact-d1-fair-five-stratum\""
           << ",\"rootFullWidth\":true,\"maximumSelectiveWork\":"
           << kMaximumSelectiveWork << ",\"maximumCacheEntries\":"
           << kMaximumCacheEntries << ",\"residentTargetBytes\":"
           << kResidentTargetBytes << ",\"fallback\":\"completed-fair-d4\""
           << ",\"worstD5W2Work\":" << kWorstD5W2Work
           << ",\"worstD5W2Cache\":" << kWorstD5W2Cache << "},\n"
           << "  \"selection\":{\"metric\":\"half mean plus half worst-half paired equal-log-score-moves utility\""
           << ",\"lowerTailFloorRatio\":" << kLowerTailFloorRatio
           << ",\"uniformWeak\":" << (uniform_weak ? "true" : "false")
           << ",\"phaseContingencyRan\":"
           << (uniform_weak ? "true" : "false")
           << ",\"selected\":";
  writeSpec(artifact, selected);
  artifact << "},\n  \"training\":{\"seedStart\":" << kTrainingSeedStart
           << ",\"baseline\":";
  writeCohort(artifact, training_baseline, training_baseline_summary);
  artifact << ",\"menu\":[";
  for (std::size_t index = 0; index < menu.size(); ++index) {
    if (index != 0) artifact << ',';
    artifact << "{\"cohort\":";
    writeCohort(artifact, menu[index].cohort, menu[index].summary);
    artifact << ",\"comparison\":";
    writeComparison(artifact, menu[index].comparison);
    artifact << '}';
  }
  artifact << "]},\n  \"trainingMemoryOk\":"
           << (training_memory_ok ? "true" : "false")
           << ",\n  \"heldout\":";
  if (heldout) writeStage(artifact, *heldout); else artifact << "null";
  artifact << ",\n  \"heldoutPassed\":"
           << (heldout_passed ? "true" : "false")
           << ",\n  \"screen\":";
  if (screen) writeStage(artifact, *screen); else artifact << "null";
  artifact << ",\n  \"screenPassed\":"
           << (screen_passed ? "true" : "false")
           << ",\n  \"confirmation\":";
  if (confirmation) writeStage(artifact, *confirmation);
  else artifact << "null";
  artifact << ",\n  \"confirmationPassed\":"
           << (confirmation_passed ? "true" : "false")
           << ",\n  \"qualified\":"
           << (heldout_passed && screen_passed && confirmation_passed
                   ? "true"
                   : "false")
           << ",\n  \"peakRssBytes\":" << peakRssBytes()
           << ",\n  \"totalWallSeconds\":" << total_wall << "\n}\n";
  artifact.close();

  output << std::fixed << std::setprecision(6)
         << "FAIR_SELECTIVE_DEPTH_RESULT {\"selected\":\"" << selected.name
         << "\",\"uniformWeak\":" << (uniform_weak ? "true" : "false")
         << ",\"trainingUtility\":"
         << best->comparison.robust_training_utility
         << ",\"heldoutPassed\":" << (heldout_passed ? "true" : "false")
         << ",\"screenRan\":" << (screen ? "true" : "false")
         << ",\"screenPassed\":" << (screen_passed ? "true" : "false")
         << ",\"confirmationRan\":"
         << (confirmation ? "true" : "false")
         << ",\"confirmationPassed\":"
         << (confirmation_passed ? "true" : "false")
         << ",\"peakRssBytes\":" << peakRssBytes()
         << ",\"totalWallSeconds\":" << total_wall
         << ",\"artifact\":\"" << options.output << "\"}\n";
  return 0;
}

}  // namespace drop7::fair_selective_depth

#ifndef DROP7_FAIR_SELECTIVE_DEPTH_NO_MAIN
int main(int argc, char** argv) {
  try {
    if (argc >= 2 && std::string_view(argv[1]) == "--self-test") {
      return drop7::fair_selective_depth::selfTest(std::cout) ? EXIT_SUCCESS
                                                              : EXIT_FAILURE;
    }
    if (argc >= 2 && std::string_view(argv[1]) == "--run") {
      const auto options =
          drop7::fair_selective_depth::parseOptions(argc, argv, 2);
      return drop7::fair_selective_depth::run(options, std::cout);
    }
    std::cerr << "usage: drop7_fair_selective_depth --self-test | --run "
                 "[--output PATH]\n";
    return 2;
  } catch (const std::exception& error) {
    std::cerr << "error: " << error.what() << '\n';
    return 1;
  }
}
#endif