Drop7 Research
approaches/fair-expectimax/cem/fair-cem-depth4-interaction.cppC++913 lines · 37.5 KB
// Applies the fixed fair-CEM coefficients to the full-width depth-four search
// to measure their interaction without optimizing or modifying a coefficient.
#define DROP7_FAIR_ONLY_DEPTH4_LIBRARY
#include "../reference/fair-only-depth4.cpp"
#undef DROP7_FAIR_ONLY_DEPTH4_LIBRARY

#include <sstream>

namespace drop7::fair_cem_depth4_interaction {

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

constexpr int kCoefficientCount = 8;
constexpr int kDepth = 4;
constexpr int kChanceSamples = d4::kChanceSamples;
constexpr std::uint64_t kMaximumWork = d4::kMaximumWork;
constexpr std::size_t kMaximumCacheEntries = d4::kMaximumCacheEntries;
constexpr int kMaximumMoves = 1'000;
constexpr int kParallelism = 4;
constexpr int kTrainingGames = 16;
constexpr int kHeldoutGames = 16;
constexpr int kScreenGames = 8;
constexpr int kConfirmationGames = 16;
constexpr double kTailFraction = 0.25;
constexpr std::uint32_t kTrainingStart = 0x3da2'0000u;
constexpr std::uint32_t kHeldoutStart = 0x3da3'0000u;
constexpr std::uint32_t kScreenStart = 0x3eab'0000u;
constexpr std::uint32_t kConfirmationStart = 0x3eac'0000u;

using Vector = std::array<double, kCoefficientCount>;

// Exact normalized doubles stored by the fair-CEM optimizer's JSON artifact
// and checkpoint; this executable treats them as fixed inputs.
constexpr Vector kFrozenNormalized{{
    0.29781767106241563,
    0.095798932122743208,
    0.081211644067223879,
    0.28528175288153812,
    -0.18236227525958248,
    0.35842254554785136,
    0.12603672153429599,
    -0.051960417474261361,
}};
constexpr double kLogMultiplierBound = 0.6931471805599453;

static_assert(kLevelBonus == 7'000);
static_assert(kDepth == d4::kCandidateDepth && kChanceSamples == 5);
static_assert(kMaximumWork == 3'200'000);
static_assert(kMaximumCacheEntries == 60'000);
static_assert(kMaximumWork > d4::kWorstCaseD4Work);
static_assert(kMaximumCacheEntries > d4::kWorstCaseD4CacheEntries);
static_assert(kMaximumMoves >= 1'000);
static_assert(kTrainingStart + kTrainingGames < kHeldoutStart);
static_assert(kHeldoutStart + kHeldoutGames < kScreenStart);
static_assert(kScreenStart + kScreenGames < kConfirmationStart);
static_assert((kTrainingStart >> 24) != 0x7du &&
              (kTrainingStart >> 24) != 0xd7u);
static_assert((kHeldoutStart >> 24) != 0x7du &&
              (kHeldoutStart >> 24) != 0xd7u);
static_assert((kScreenStart >> 24) != 0x7du &&
              (kScreenStart >> 24) != 0xd7u);
static_assert((kConfirmationStart >> 24) != 0x7du &&
              (kConfirmationStart >> 24) != 0xd7u);

struct Coefficients {
  double direct_trigger_multiplier = 1.0;
  double latent_release_multiplier = 1.0;
  double cover_debt_multiplier = 1.0;
  double altitude_danger_rise_multiplier = 1.0;
  double low_number_clog_multiplier = 1.0;
  double next_disc_quiet_readiness_delta = 0.0;
  double revealed_cover_reward = 0.0;
  double additional_wave_reward = 0.0;
};

Coefficients decode(const Vector& vector) {
  Coefficients result;
  result.direct_trigger_multiplier =
      std::exp(vector[0] * kLogMultiplierBound);
  result.latent_release_multiplier =
      std::exp(vector[1] * kLogMultiplierBound);
  result.cover_debt_multiplier =
      std::exp(vector[2] * kLogMultiplierBound);
  result.altitude_danger_rise_multiplier =
      std::exp(vector[3] * kLogMultiplierBound);
  result.low_number_clog_multiplier =
      std::exp(vector[4] * kLogMultiplierBound);
  result.next_disc_quiet_readiness_delta = vector[5];
  result.revealed_cover_reward = 600.0 * vector[6];
  result.additional_wave_reward = 1'500.0 * vector[7];
  return result;
}

double parameterizedLeaf(const State& state,
                         const Coefficients& coefficients) {
  if (state.game_over) return fair::kFairTerminalUtility;
  const fair::FairFeatures features = fair::extractFairFeatures(state);
  const auto& f = features.heuristic;
  // Accumulation order matches fairLeaf. The all-zero normalized vector is
  // therefore exact stock fair D4, including floating-point tie behavior.
  double result = 0.0;
  result += fair::kOpenColumnsWeight * f.open_columns;
  result += coefficients.altitude_danger_rise_multiplier *
            fair::kHeightLoadWeight * f.height_load;
  result += coefficients.cover_debt_multiplier * fair::kSolidCellsWeight *
            f.solid_cells;
  result += coefficients.cover_debt_multiplier * fair::kCrackedCellsWeight *
            f.cracked_cells;
  result += fair::kNumberedCellsWeight * f.numbered_cells;
  result += coefficients.low_number_clog_multiplier *
            fair::kHighLowNumbersWeight * f.high_low_numbers;
  result += coefficients.direct_trigger_multiplier *
            fair::kDirectPotentialWeight * f.direct_potential;
  result += coefficients.latent_release_multiplier *
            fair::kLatentChainPotentialWeight * f.latent_chain_potential;
  result += coefficients.latent_release_multiplier *
            fair::kCrackedExposureWeight * f.cracked_exposure;
  result += coefficients.latent_release_multiplier *
            fair::kSolidExposureWeight * f.solid_exposure;
  result += coefficients.low_number_clog_multiplier *
            fair::kAdjacentOnesWeight * f.adjacent_ones;
  result += coefficients.low_number_clog_multiplier *
            fair::kTripleTwosWeight * f.triple_twos;
  result += coefficients.low_number_clog_multiplier *
            fair::kDeadLowNumbersWeight * f.dead_low_numbers;
  result += coefficients.altitude_danger_rise_multiplier *
            fair::kCoveredHeightRiskWeight * features.covered_height_risk;
  result += coefficients.altitude_danger_rise_multiplier *
            fair::kLowNumberHeightRiskWeight * features.low_number_height_risk;
  result += coefficients.altitude_danger_rise_multiplier *
            fair::kDangerHeightSquaredWeight *
            features.danger_height_squared;
  result += fair::kRoughnessWeight * features.roughness;
  result += coefficients.altitude_danger_rise_multiplier *
            fair::kRisePressureWeight * features.rise_pressure;
  result += fair::kNextDiscVerticalOptionsWeight *
            features.next_disc_vertical_options;
  const double extra_readiness =
      fair::kNextDiscVerticalOptionsWeight *
          features.next_disc_vertical_options +
      300.0 * f.quiet_build_options + 600.0 * f.quiet_direct_gain +
      600.0 * f.trigger_readiness + 1'200.0 * f.rise_trigger_readiness;
  result += coefficients.next_disc_quiet_readiness_delta * extra_readiness;
  return result;
}

double transitionBonus(const MoveResult& move,
                       const Coefficients& coefficients) {
  std::uint64_t revealed = 0;
  for (const Wave& wave : move.waves) {
    revealed += static_cast<std::uint64_t>(wave.revealed);
  }
  const int additional_waves =
      std::max(0, static_cast<int>(move.waves.size()) - 1);
  return coefficients.revealed_cover_reward * revealed +
         coefficients.additional_wave_reward * additional_waves;
}

class WorkLimitReached : public std::exception {};

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

struct SearchContext {
  explicit SearchContext(const Coefficients& run_coefficients)
      : coefficients(run_coefficients) {}

  const Coefficients& coefficients;
  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,
                       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, fair::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 += fair::kTerminalUtility;
      continue;
    }
    const double score_delta = static_cast<double>(move.score_delta);
    result.expected_score += score_delta;
    const double transition =
        score_delta + transitionBonus(move, context.coefficients);
    if (move.state.game_over) {
      result.value += transition + fair::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 += transition + 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 = parameterizedLeaf(state, context.coefficients);
  if (!std::isfinite(value)) {
    throw std::runtime_error("CEM D4 leaf returned non-finite value");
  }
  return value;
}

double bestFutureValue(const State& state, int depth,
                       SearchContext& context) {
  ++context.nodes;
  checkBudget(context);
  if (state.game_over) return fair::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();
  for (const int column : cfpi::detail::kColumnOrder) {
    if (!isLegal(state.board, column)) continue;
    best = std::max(best, evaluateAction(state, column, depth, context).value);
  }
  if (!std::isfinite(best)) best = fair::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& canonical, int depth,
                            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(canonical.board, column)) continue;
    const ActionValue candidate = evaluateAction(canonical, column, depth,
                                                  context);
    result.values[column] = candidate.value;
    result.expected_scores[column] = candidate.expected_score;
    if (candidate.value > result.value) {
      result.value = candidate.value;
      result.action = column;
    }
  }
  return result;
}

struct SearchDecision {
  int action = -1;
  int depth3_action = -1;
  int completed_depth = 0;
  bool complete = false;
  bool switched_from_depth3 = 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 Coefficients& coefficients) {
  if (source.game_over) return {};
  bool mirrored = false;
  const State canonical = cfpi::detail::canonicalState(source, mirrored);
  SearchContext context(coefficients);
  RootEvaluation completed;
  int completed_depth = 0;
  int depth3_action = -1;
  for (int depth = 1; depth <= kDepth; ++depth) {
    try {
      completed = rootDecision(canonical, depth, context);
      if (completed.action < 0) break;
      completed_depth = depth;
      if (depth == d4::kBaselineDepth) depth3_action = completed.action;
    } catch (const WorkLimitReached&) {
      break;
    }
  }
  int action = completed.action;
  if (action < 0) action = centerFirstMove(canonical.board);
  if (depth3_action < 0) depth3_action = action;
  SearchDecision result;
  result.action = mirrored ? kBoardSize - 1 - action : action;
  result.depth3_action =
      mirrored ? kBoardSize - 1 - depth3_action : depth3_action;
  result.completed_depth = completed_depth;
  result.complete = completed_depth == kDepth;
  result.switched_from_depth3 = result.action != result.depth3_action;
  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 canonical_column = 0; canonical_column < kBoardSize;
         ++canonical_column) {
      const int source_column = mirrored
                                    ? kBoardSize - 1 - canonical_column
                                    : canonical_column;
      result.root_values[source_column] = completed.values[canonical_column];
      result.root_expected_scores[source_column] =
          completed.expected_scores[canonical_column];
    }
  }
  return result;
}

d4::GameResult runCompositeGame(std::uint32_t seed, std::string_view label) {
  const auto started = std::chrono::steady_clock::now();
  const Coefficients coefficients = decode(kFrozenNormalized);
  State state = initialHeadlessState(seed);
  d4::GameResult result;
  result.seed = seed;
  while (!state.game_over && state.moves_played < kMaximumMoves) {
    const SearchDecision decision = chooseAction(state, coefficients);
    if (!decision.complete || decision.completed_depth != kDepth) {
      throw std::runtime_error("frozen CEM D4 did not complete");
    }
    if (!isLegal(state.board, decision.action) ||
        !isLegal(state.board, decision.depth3_action)) {
      throw std::runtime_error("frozen CEM D4 decision was illegal");
    }
    result.depth_switches += decision.switched_from_depth3;
    ++result.action_counts[decision.action];
    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);
    MoveResult move;
    if (!playHeadlessMove(state, seed, decision.action, move)) {
      throw std::runtime_error("frozen CEM D4 transition failed");
    }
    d4::observeMove(move, result);
  }
  result.score = state.score;
  result.moves = state.moves_played;
  result.censored = !state.game_over;
  result.peak_rss_bytes = d4::peakRssBytes();
  result.elapsed_seconds = std::chrono::duration<double>(
                               std::chrono::steady_clock::now() - started)
                               .count();
  d4::reportGame(label, result);
  return result;
}

struct Cohort {
  std::vector<d4::GameResult> stock;
  std::vector<d4::GameResult> composite;
  double wall_seconds = 0.0;
};

Cohort runCohort(std::uint32_t seed_start, int games,
                 std::string_view phase) {
  if (games < 1) throw std::invalid_argument("empty CEM D4 cohort");
  const auto started = std::chrono::steady_clock::now();
  Cohort result;
  result.stock.resize(static_cast<std::size_t>(games));
  result.composite.resize(static_cast<std::size_t>(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;
        const std::uint32_t seed =
            seed_start + static_cast<std::uint32_t>(game);
        result.stock[static_cast<std::size_t>(game)] = d4::runDepth4Game(
            seed, std::string(phase) + "-stock-fair-d4");
        result.composite[static_cast<std::size_t>(game)] = runCompositeGame(
            seed, std::string(phase) + "-frozen-cem-d4");
      }
    }));
  }
  for (auto& worker : workers) worker.get();
  result.wall_seconds = std::chrono::duration<double>(
                            std::chrono::steady_clock::now() - started)
                            .count();
  return result;
}

double lowerTailMean(std::vector<double> values, double fraction) {
  if (values.empty() || fraction <= 0.0 || fraction > 1.0) {
    throw std::invalid_argument("invalid CEM D4 lower-tail request");
  }
  std::sort(values.begin(), values.end());
  const double mass = fraction * values.size();
  const int whole = static_cast<int>(std::floor(mass));
  const double fractional = mass - whole;
  double total = 0.0;
  for (int index = 0; index < whole; ++index) {
    total += values[static_cast<std::size_t>(index)];
  }
  if (fractional > 0.0) {
    total += fractional * values[static_cast<std::size_t>(whole)];
  }
  return total / mass;
}

d4::PairedSummary pairedSummary(const Cohort& cohort) {
  if (cohort.stock.size() != cohort.composite.size() ||
      cohort.stock.empty()) {
    throw std::invalid_argument("invalid CEM D4 paired cohort");
  }
  std::vector<double> scores;
  std::vector<double> moves;
  std::vector<double> cleared;
  std::vector<double> revealed;
  for (std::size_t game = 0; game < cohort.stock.size(); ++game) {
    scores.push_back(static_cast<double>(cohort.composite[game].score) -
                     static_cast<double>(cohort.stock[game].score));
    moves.push_back(static_cast<double>(cohort.composite[game].moves) -
                    static_cast<double>(cohort.stock[game].moves));
    cleared.push_back(
        static_cast<double>(cohort.composite[game].numbered_cleared) -
        static_cast<double>(cohort.stock[game].numbered_cleared));
    revealed.push_back(
        static_cast<double>(cohort.composite[game].covers_revealed) -
        static_cast<double>(cohort.stock[game].covers_revealed));
  }
  return {d4::differences(scores), d4::differences(moves),
          d4::differences(cleared), d4::differences(revealed)};
}

struct Analysis {
  d4::Summary stock;
  d4::Summary composite;
  d4::PairedSummary paired;
  double stock_tail_score = 0.0;
  double composite_tail_score = 0.0;
  double stock_tail_moves = 0.0;
  double composite_tail_moves = 0.0;
};

Analysis analyze(const Cohort& cohort) {
  Analysis result;
  result.stock = d4::summarize(cohort.stock, kDepth);
  result.composite = d4::summarize(cohort.composite, kDepth);
  result.paired = pairedSummary(cohort);
  std::vector<double> stock_scores;
  std::vector<double> composite_scores;
  std::vector<double> stock_moves;
  std::vector<double> composite_moves;
  for (std::size_t game = 0; game < cohort.stock.size(); ++game) {
    stock_scores.push_back(static_cast<double>(cohort.stock[game].score));
    composite_scores.push_back(
        static_cast<double>(cohort.composite[game].score));
    stock_moves.push_back(static_cast<double>(cohort.stock[game].moves));
    composite_moves.push_back(
        static_cast<double>(cohort.composite[game].moves));
  }
  result.stock_tail_score = lowerTailMean(stock_scores, kTailFraction);
  result.composite_tail_score =
      lowerTailMean(composite_scores, kTailFraction);
  result.stock_tail_moves = lowerTailMean(stock_moves, kTailFraction);
  result.composite_tail_moves =
      lowerTailMean(composite_moves, kTailFraction);
  return result;
}

bool passesGate(const Analysis& analysis) {
  return analysis.composite.mean_score > analysis.stock.mean_score &&
         analysis.composite.mean_moves > analysis.stock.mean_moves &&
         analysis.composite_tail_score >= analysis.stock_tail_score &&
         analysis.composite_tail_moves >= analysis.stock_tail_moves;
}

void writeVector(std::ostream& output, const Vector& vector) {
  output << '[';
  for (int index = 0; index < kCoefficientCount; ++index) {
    if (index != 0) output << ',';
    output << vector[static_cast<std::size_t>(index)];
  }
  output << ']';
}

void writeCoefficients(std::ostream& output,
                       const Coefficients& coefficients) {
  output << "{\"directTriggerMultiplier\":"
         << coefficients.direct_trigger_multiplier
         << ",\"latentReleaseMultiplier\":"
         << coefficients.latent_release_multiplier
         << ",\"coverDebtMultiplier\":"
         << coefficients.cover_debt_multiplier
         << ",\"altitudeDangerRiseMultiplier\":"
         << coefficients.altitude_danger_rise_multiplier
         << ",\"lowNumberClogMultiplier\":"
         << coefficients.low_number_clog_multiplier
         << ",\"nextDiscQuietReadinessDelta\":"
         << coefficients.next_disc_quiet_readiness_delta
         << ",\"revealedCoverReward\":"
         << coefficients.revealed_cover_reward
         << ",\"additionalWaveReward\":"
         << coefficients.additional_wave_reward << '}';
}

void writeCohort(std::ostream& output, std::uint32_t seed_start,
                 const Cohort& cohort, const Analysis& analysis,
                 bool passed) {
  output << "{\"seedStart\":" << seed_start
         << ",\"games\":" << cohort.stock.size()
         << ",\"maximumMoves\":" << kMaximumMoves
         << ",\"stockFairD4\":";
  d4::writeSummary(output, analysis.stock);
  output << ",\"frozenCemD4\":";
  d4::writeSummary(output, analysis.composite);
  output << ",\"paired\":";
  d4::writePaired(output, analysis.paired);
  output << ",\"lowerTail25\":{\"stockScore\":"
         << analysis.stock_tail_score << ",\"compositeScore\":"
         << analysis.composite_tail_score << ",\"stockMoves\":"
         << analysis.stock_tail_moves << ",\"compositeMoves\":"
         << analysis.composite_tail_moves << "},\"wallSeconds\":"
         << cohort.wall_seconds << ",\"passed\":"
         << (passed ? "true" : "false") << ",\"pairs\":[";
  for (std::size_t game = 0; game < cohort.stock.size(); ++game) {
    if (game != 0) output << ',';
    output << "{\"seed\":" << cohort.stock[game].seed
           << ",\"stockFairD4\":";
    d4::writeGame(output, cohort.stock[game]);
    output << ",\"frozenCemD4\":";
    d4::writeGame(output, cohort.composite[game]);
    output << '}';
  }
  output << "]}";
}

struct Options {
  std::string output = "/tmp/drop7-fair-cem-depth4-interaction.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 CEM D4 option value");
    }
    const std::string_view option(argv[index]);
    if (option == "--output") {
      result.output = argv[index + 1];
    } else {
      throw std::invalid_argument("unknown CEM D4 option");
    }
  }
  return result;
}

void writeArtifact(const Options& options, const Cohort& training,
                   const Analysis& training_analysis, bool training_passed,
                   const Cohort& heldout, const Analysis& heldout_analysis,
                   bool heldout_passed, const Cohort* screen,
                   const Analysis* screen_analysis, bool screen_passed,
                   const Cohort* confirmation,
                   const Analysis* confirmation_analysis,
                   bool confirmation_passed, double total_wall_seconds) {
  std::ofstream output(options.output, std::ios::trunc);
  if (!output) throw std::runtime_error("could not open CEM D4 artifact");
  output << std::setprecision(17)
         << "{\n  \"experiment\":\"frozen-fair-cem-d4-interaction\",\n"
         << "  \"preregistered\":true,\n"
         << "  \"rescueRetune\":false,\n"
         << "  \"d3ScreenFailureRemainsPrimary\":true,\n"
         << "  \"publicStateOnly\":true,\n"
         << "  \"historicalActionPlacementPriors\":false,\n"
         << "  \"sourceD3Result\":{\"artifactSha256\":"
            "\"524705e1bbe3ed409788864f94d70c5de998a94fa260b2a7ecddf677977d9a2a\","
            "\"checkpointSha256\":"
            "\"ec1356a8536077b412498c580aa5f38c261e14fa2b468fc3e9dfccc298c6fccf\","
            "\"screenScoreDelta\":-905.375,\"screenMoveDelta\":1.875,"
            "\"screenPassed\":false},\n"
         << "  \"frozenChampion\":{\"normalized\":";
  writeVector(output, kFrozenNormalized);
  output << ",\"decoded\":";
  writeCoefficients(output, decode(kFrozenNormalized));
  output << "},\n  \"scoring\":{\"levelBonus\":7000},\n"
         << "  \"search\":{\"depth\":" << kDepth
         << ",\"chanceSamples\":" << kChanceSamples
         << ",\"fullWidth\":true,\"iterativeDeepening\":true,"
         << "\"policySeed\":" << fair::kPolicySeed
         << ",\"maximumWork\":" << kMaximumWork
         << ",\"worstCaseWork\":" << d4::kWorstCaseD4Work
         << ",\"maximumCacheEntries\":" << kMaximumCacheEntries
         << ",\"worstCaseCacheEntries\":"
         << d4::kWorstCaseD4CacheEntries
         << ",\"maximumMoves\":" << kMaximumMoves
         << ",\"parallelism\":" << kParallelism << "},\n"
         << "  \"gateCriteria\":{\"trainingBothMeansImprove\":true,"
            "\"trainingLowerTail25DoesNotRegress\":true,"
            "\"heldoutBothMeansImprove\":true,"
            "\"heldoutLowerTail25DoesNotRegress\":true},\n"
         << "  \"training\":";
  writeCohort(output, kTrainingStart, training, training_analysis,
              training_passed);
  output << ",\n  \"heldout\":";
  writeCohort(output, kHeldoutStart, heldout, heldout_analysis,
              heldout_passed);
  output << ",\n  \"screen\":";
  if (screen == nullptr) {
    output << "null";
  } else {
    writeCohort(output, kScreenStart, *screen, *screen_analysis,
                screen_passed);
  }
  output << ",\n  \"confirmation\":";
  if (confirmation == nullptr) {
    output << "null";
  } else {
    writeCohort(output, kConfirmationStart, *confirmation,
                *confirmation_analysis, confirmation_passed);
  }
  const bool gate_passed = training_passed && heldout_passed;
  output << ",\n  \"trainingPassed\":"
         << (training_passed ? "true" : "false")
         << ",\n  \"heldoutPassed\":"
         << (heldout_passed ? "true" : "false")
         << ",\n  \"gatePassed\":" << (gate_passed ? "true" : "false")
         << ",\n  \"screenRan\":" << (screen != nullptr ? "true" : "false")
         << ",\n  \"screenPassed\":" << (screen_passed ? "true" : "false")
         << ",\n  \"confirmationRan\":"
         << (confirmation != nullptr ? "true" : "false")
         << ",\n  \"confirmationPassed\":"
         << (confirmation_passed ? "true" : "false")
         << ",\n  \"qualified\":"
         << (gate_passed && screen_passed && confirmation_passed ? "true"
                                                                  : "false")
         << ",\n  \"totalWallSeconds\":" << total_wall_seconds
         << ",\n  \"peakRssBytes\":" << d4::peakRssBytes() << "\n}\n";
  if (!output) throw std::runtime_error("could not write CEM D4 artifact");
}

bool nearlyEqual(double first, double second, double tolerance = 1.0e-9) {
  if (std::isinf(first) || std::isinf(second)) return first == second;
  return std::abs(first - second) <= tolerance;
}

bool selfTest(std::ostream& output) {
  std::ostringstream inherited_output;
  const bool inherited = d4::selfTest(inherited_output);
  const State state = fair::fixtureState(fair::kTypeScriptFixtures[1]);
  const Coefficients zero = decode(Vector{});
  const d4::SearchDecision stock = d4::chooseDepth4Action(state);
  const SearchDecision parity = chooseAction(state, zero);
  bool root_parity = parity.action == stock.action &&
                     parity.depth3_action == stock.depth3_action &&
                     parity.completed_depth == stock.completed_depth &&
                     parity.complete == stock.complete &&
                     parity.work == stock.work && parity.nodes == stock.nodes &&
                     parity.cache_hits == stock.cache_hits &&
                     parity.cache_entries == stock.cache_entries;
  for (int column = 0; column < kBoardSize; ++column) {
    root_parity =
        root_parity &&
        nearlyEqual(parity.root_values[column], stock.root_values[column],
                    1.0e-10) &&
        nearlyEqual(parity.root_expected_scores[column],
                    stock.root_expected_scores[column], 1.0e-10);
  }
  const bool leaf_parity = parameterizedLeaf(state, zero) == fair::fairLeaf(state);

  const Coefficients frozen = decode(kFrozenNormalized);
  const SearchDecision first = chooseAction(state, frozen);
  const SearchDecision repeat = chooseAction(state, frozen);
  State reflected = state;
  reflected.board = cfpi::detail::mirrorBoard(state.board);
  const SearchDecision mirrored = chooseAction(reflected, frozen);
  State metadata = state;
  metadata.score = 9'999'999;
  metadata.level = 88;
  metadata.moves_played = 654;
  const SearchDecision metadata_decision = chooseAction(metadata, frozen);
  const bool deterministic =
      repeat.action == first.action &&
      repeat.depth3_action == first.depth3_action &&
      repeat.work == first.work && repeat.nodes == first.nodes &&
      repeat.cache_hits == first.cache_hits &&
      repeat.cache_entries == first.cache_entries;
  const bool reflection_safe =
      mirrored.action == kBoardSize - 1 - first.action &&
      mirrored.depth3_action == kBoardSize - 1 - first.depth3_action &&
      mirrored.work == first.work &&
      parameterizedLeaf(state, frozen) ==
          parameterizedLeaf(reflected, frozen);
  const bool public_only = metadata_decision.action == first.action &&
                           metadata_decision.depth3_action ==
                               first.depth3_action &&
                           metadata_decision.work == first.work &&
                           parameterizedLeaf(state, frozen) ==
                               parameterizedLeaf(metadata, frozen);
  const bool legal = isLegal(state.board, first.action) &&
                     isLegal(state.board, first.depth3_action);
  const bool bounded = first.work <= kMaximumWork &&
                       first.cache_entries <= kMaximumCacheEntries;
  const bool completion_proven =
      kMaximumWork > d4::kWorstCaseD4Work &&
      kMaximumCacheEntries > d4::kWorstCaseD4CacheEntries;
  MoveResult synthetic;
  synthetic.waves = {{1, 2, 3, 0}, {2, 1, 5, 0}, {3, 4, 7, 0}};
  Coefficients transition;
  transition.revealed_cover_reward = 11.0;
  transition.additional_wave_reward = 101.0;
  const bool transition_exact =
      transitionBonus(synthetic, transition) ==
      15.0 * 11.0 + 2.0 * 101.0;
  const bool frozen_exact =
      nearlyEqual(frozen.direct_trigger_multiplier, 1.229283499618433) &&
      nearlyEqual(frozen.latent_release_multiplier, 1.0686570424928017) &&
      nearlyEqual(frozen.cover_debt_multiplier, 1.0579061475839819) &&
      nearlyEqual(frozen.altitude_danger_rise_multiplier,
                  1.218648237825525) &&
      nearlyEqual(frozen.low_number_clog_multiplier, 0.88125883714791231) &&
      frozen.next_disc_quiet_readiness_delta ==
          0.35842254554785136 &&
      nearlyEqual(frozen.revealed_cover_reward, 75.622032920577595) &&
      nearlyEqual(frozen.additional_wave_reward, -77.940626211392043);
  const bool tail_exact = nearlyEqual(
      lowerTailMean({1.0, 2.0, 10.0, 20.0, 30.0, 40.0}, 0.25),
      4.0 / 3.0);
  const bool protocol =
      kLevelBonus == 7'000 && kDepth == 4 && kChanceSamples == 5 &&
      kMaximumMoves >= 1'000 && kTrainingGames == 16 &&
      kHeldoutGames == 16 && kScreenGames == 8 &&
      kConfirmationGames == 16 && kTrainingStart == 0x3da2'0000u &&
      kHeldoutStart == 0x3da3'0000u && kScreenStart == 0x3eab'0000u &&
      kConfirmationStart == 0x3eac'0000u;
  const bool passed = inherited && leaf_parity && root_parity &&
                      deterministic && reflection_safe && public_only &&
                      legal && bounded && completion_proven &&
                      transition_exact && frozen_exact && tail_exact &&
                      protocol;
  output << std::boolalpha << std::setprecision(12)
         << "FAIR_CEM_DEPTH4_INTERACTION_SELF_TEST {\"passed\":" << passed
         << ",\"inheritedD4Test\":" << inherited
         << ",\"zeroLeafParity\":" << leaf_parity
         << ",\"zeroRootParity\":" << root_parity
         << ",\"deterministic\":" << deterministic
         << ",\"reflectionSafe\":" << reflection_safe
         << ",\"publicStateOnly\":" << public_only
         << ",\"legal\":" << legal << ",\"bounded\":" << bounded
         << ",\"completionProven\":" << completion_proven
         << ",\"transitionRewardsExact\":" << transition_exact
         << ",\"frozenCoefficientsExact\":" << frozen_exact
         << ",\"fractionalTailExact\":" << tail_exact
         << ",\"fixedProtocol\":" << protocol
         << ",\"work\":" << first.work
         << ",\"cacheEntries\":" << first.cache_entries
         << ",\"worstCaseWork\":" << d4::kWorstCaseD4Work
         << ",\"worstCaseCache\":" << d4::kWorstCaseD4CacheEntries
         << ",\"levelBonus\":" << kLevelBonus << "}\n";
  return passed;
}

int run(const Options& options, std::ostream& output) {
  const auto started = std::chrono::steady_clock::now();
  const Cohort training =
      runCohort(kTrainingStart, kTrainingGames, "training");
  const Analysis training_analysis = analyze(training);
  const bool training_passed = passesGate(training_analysis);

  // Run the entire heldout regardless of fitting metrics.  No choice or
  // retuning occurs between the two fixed cohorts.
  const Cohort heldout =
      runCohort(kHeldoutStart, kHeldoutGames, "heldout");
  const Analysis heldout_analysis = analyze(heldout);
  const bool heldout_passed = passesGate(heldout_analysis);
  const bool gate_passed = training_passed && heldout_passed;

  Cohort screen;
  Analysis screen_analysis;
  bool screen_passed = false;
  if (gate_passed) {
    screen = runCohort(kScreenStart, kScreenGames, "screen");
    screen_analysis = analyze(screen);
    screen_passed = passesGate(screen_analysis);
  }

  Cohort confirmation;
  Analysis confirmation_analysis;
  bool confirmation_passed = false;
  if (screen_passed) {
    confirmation = runCohort(kConfirmationStart, kConfirmationGames,
                             "confirmation");
    confirmation_analysis = analyze(confirmation);
    confirmation_passed = passesGate(confirmation_analysis);
  }

  const double total_wall_seconds = std::chrono::duration<double>(
                                          std::chrono::steady_clock::now() -
                                          started)
                                          .count();
  writeArtifact(options, training, training_analysis, training_passed,
                heldout, heldout_analysis, heldout_passed,
                gate_passed ? &screen : nullptr,
                gate_passed ? &screen_analysis : nullptr, screen_passed,
                screen_passed ? &confirmation : nullptr,
                screen_passed ? &confirmation_analysis : nullptr,
                confirmation_passed, total_wall_seconds);
  output << std::fixed << std::setprecision(3)
         << "FAIR_CEM_DEPTH4_INTERACTION_RESULT {\"trainingStockScore\":"
         << training_analysis.stock.mean_score
         << ",\"trainingCompositeScore\":"
         << training_analysis.composite.mean_score
         << ",\"trainingStockMoves\":"
         << training_analysis.stock.mean_moves
         << ",\"trainingCompositeMoves\":"
         << training_analysis.composite.mean_moves
         << ",\"trainingPassed\":"
         << (training_passed ? "true" : "false")
         << ",\"heldoutStockScore\":"
         << heldout_analysis.stock.mean_score
         << ",\"heldoutCompositeScore\":"
         << heldout_analysis.composite.mean_score
         << ",\"heldoutStockMoves\":"
         << heldout_analysis.stock.mean_moves
         << ",\"heldoutCompositeMoves\":"
         << heldout_analysis.composite.mean_moves
         << ",\"heldoutPassed\":"
         << (heldout_passed ? "true" : "false")
         << ",\"gatePassed\":" << (gate_passed ? "true" : "false")
         << ",\"screenRan\":" << (gate_passed ? "true" : "false")
         << ",\"screenPassed\":" << (screen_passed ? "true" : "false")
         << ",\"confirmationRan\":"
         << (screen_passed ? "true" : "false")
         << ",\"confirmationPassed\":"
         << (confirmation_passed ? "true" : "false")
         << ",\"peakRssBytes\":" << d4::peakRssBytes()
         << ",\"totalWallSeconds\":" << total_wall_seconds
         << ",\"artifact\":\"" << options.output << "\"}\n";
  return 0;
}

}  // namespace drop7::fair_cem_depth4_interaction

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